diff --git a/.eslintrc.js b/.eslintrc.js index 489a715b..71cc6577 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,10 @@ module.exports = { extends: ["airbnb", "prettier"], + parserOptions: { + // Override airbnb-config's ecmaVersion: 2018 so optional chaining + // (used by gui.js / util.js) parses without errors. + ecmaVersion: 2020, + }, rules: { "no-param-reassign": "off", "class-methods-use-this": "off", diff --git a/README.md b/README.md index a81765dd..8eb7dc50 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,14 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc clusterConfig:{ // The configuration for the clusters }, + nodePopup:{ + show: boolean, + content: function|HTMLElement|string|null, + config:{ + // Leaflet popup options + }, + onOpen: function, + }, baseOptions:{ // The global configuration for Echarts specifically for the map. } @@ -455,6 +463,20 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc The `linkStyle` property is used to customize the style of the links. The list of all available style properties can be found in the [Echarts documentation](https://echarts.apache.org/en/option.html#series-lines.lineStyle). + `nodePopup` displays a Leaflet popup when a map node is clicked. + Set `show` to `true` to enable it. If `content` is `null`, the popup shows the default node details. + If you need custom content, set `content` to a function that receives the clicked node and returns a DOM element or a string. + Use `config` to pass Leaflet popup options. Use `onOpen` if you need to run code after the popup opens. + + **Note:** `content` can also return a promise. + If the user clicks multiple nodes quickly, only the latest popup result is shown. + Older requests may still finish in the background, but their result is ignored. + Avoid putting important side effects in `content`, because they may still run after a newer click. + + **Security:** when `content` returns a string, Leaflet treats it as HTML. + If the string includes node data from a remote API or user input, escape it first. + The safer option is to return a DOM element and set text with `textContent`, like the built-in popup does. + - `mapTileConfig` The configuration for the map tiles. You can use multiple tiles by passing an array of tile configurations. diff --git a/public/example_templates/netjsonmap-indoormap-overlay.html b/public/example_templates/netjsonmap-indoormap-overlay.html index d65ffe8c..6911652e 100644 --- a/public/example_templates/netjsonmap-indoormap-overlay.html +++ b/public/example_templates/netjsonmap-indoormap-overlay.html @@ -73,6 +73,30 @@ #indoormap-container .njg-container .hidden .sideBarHandle { left: 35px; } + .njg-container .njg-sideBar { + display: none; + } + .njg-container .leaflet-popup-tip-container { + bottom: -3.5%; + } + .njg-container .leaflet-popup-tip { + box-shadow: none; + } + .njg-container .default-popup .njg-popup-button-container { + text-align: center; + } + .njg-container .default-popup .njg-popup-button { + padding: 6px 12px; + border: none; + border-radius: 5px; + background-color: black; + color: white; + cursor: pointer; + margin-top: 5px; + } + .njg-container .default-popup .njg-popup-button:hover { + background-color: rgb(85, 85, 85); + } @@ -81,6 +105,17 @@ diff --git a/src/css/netjsongraph.css b/src/css/netjsongraph.css index 951d9ec3..4bd4af13 100755 --- a/src/css/netjsongraph.css +++ b/src/css/netjsongraph.css @@ -319,6 +319,10 @@ user-select: text !important; } +.njg-container.njg-hide-tooltip .njg-tooltip { + display: none !important; +} + .njg-container .njg-tooltip .njg-closeButton { display: none; } @@ -362,6 +366,34 @@ font-size: 18px; } +.njg-container .default-popup { + padding: 18px; +} +.njg-container .default-popup .njg-tooltip-item { + display: flex; + align-items: center; + margin-bottom: 8px; + font-size: 14px; +} + +.njg-container .default-popup .njg-tooltip-item:last-child { + margin-bottom: 0; +} + +.njg-container .default-popup .njg-tooltip-key { + flex-basis: 45%; + font-weight: 600; + text-transform: capitalize; + color: black; +} + +.njg-container .default-popup .njg-tooltip-value { + flex: 1; + overflow-wrap: anywhere; + word-break: normal; + color: black; +} + @media only screen and (max-width: 850px) { .njg-container .njg-sideBar { top: 0; diff --git a/src/js/netjsongraph.config.js b/src/js/netjsongraph.config.js index b4795793..e56d44c9 100644 --- a/src/js/netjsongraph.config.js +++ b/src/js/netjsongraph.config.js @@ -245,6 +245,16 @@ const NetJSONGraphDefaultConfig = { }, ], }, + nodePopup: { + show: false, + content: null, + // `offset` is intentionally not set — Leaflet picks an offset that + // accounts for its own anchor; we only override autoPan defaults here. + config: { + autoPan: true, + autoPanPadding: [25, 25], + }, + }, }, mapTileConfig: [ { diff --git a/src/js/netjsongraph.gui.js b/src/js/netjsongraph.gui.js index cec8ff2e..be8cfc20 100644 --- a/src/js/netjsongraph.gui.js +++ b/src/js/netjsongraph.gui.js @@ -278,6 +278,204 @@ class NetJSONGraphGUI { }; } + /** + * Canonical lookup order for a node's location. `prepareData` normalizes a + * node by setting `properties.location = node.location`, so the + * post-pipeline value lives on `properties.location`; we read it first + * and fall back to the top-level NetJSON field for nodes that bypass + * `prepareData`. + * + * @param {Object} node + * @returns {{lat: number, lng: number}|undefined} + */ + getNodeLocation(node) { + return node?.properties?.location || node?.location; + } + + /** + * Load and display a Leaflet popup for a node on the map. + * + * Resolves `mapOptions.nodePopup.content`: + * - if `null` → uses the built-in `createDefaultPopupContent` + * - if a function → calls it with `this` = the netjsongraph instance and + * awaits the result (the function may be async) + * + * Concurrency: only the latest invocation's result is rendered. Earlier + * pending content promises are not cancelled; their resolved/rejected + * values are discarded. Callers should not assume their content function's + * return value reaches the screen. + * + * Failure handling: + * - if content building throws (sync or async), the URL fragment for the + * current bookmarkable action is cleared and no popup is opened + * - if `onOpen` throws after the popup is shown, the popup is removed + * and the URL fragment is cleared (via the popup's own remove handler) + * + * @param {Object} node - The node data containing location and properties. + * @returns {Promise} + */ + async loadNodePopup(node) { + const {self} = this; + if (!self.leaflet) { + console.error("Leaflet map not available. Cannot load popup."); + return; + } + const nodeLocation = this.getNodeLocation(node); + if (!nodeLocation) { + console.error("Node location not available. Cannot load popup."); + return; + } + const bookmarkableActionId = + self.config.bookmarkableActions && self.config.bookmarkableActions.id; + const popupRequest = {}; + self.leaflet.currentPopupRequest = popupRequest; + // Track whether tooltip/labels were already hidden by an open popup, so + // we can restore them if replacement content generation fails. + const hadOpenPopup = Boolean(self.leaflet.currentPopup); + if (self.leaflet.currentPopup) { + // Null out before remove() so the old popup's "remove" handler bails on + // the currentPopup !== popup check. Otherwise it runs the user-close + // cleanup path (tooltip/label restore + URL fragment removal), which on + // popstate restoration of the same node ends up wiping the URL fragment + // we just restored. + const previousPopup = self.leaflet.currentPopup; + self.leaflet.currentPopup = null; + previousPopup.remove(); + } + let popupContent = self.config.mapOptions.nodePopup.content; + if (popupContent == null) { + popupContent = this.createDefaultPopupContent(node); + } else if (typeof popupContent === "function") { + try { + // Matches the project-wide callback convention: callbacks receive the + // netjsongraph instance as `this` and any context as positional args. + popupContent = await popupContent.call(self, node); + } catch (error) { + if (self.leaflet.currentPopupRequest !== popupRequest) { + return; + } + self.utils.removeUrlFragment(bookmarkableActionId, "nodeId"); + self.leaflet.currentPopupRequest = null; + // If we tore down a previous popup before content generation failed, + // its remove handler was bypassed — restore tooltip/labels manually so + // the map doesn't stay in popup-open visual state with no popup. + if (hadOpenPopup) { + self.utils.setTooltipVisibility(self, true); + self.utils.updateLabelVisibility(self, true); + } + console.error("Failed to build node popup content:", error); + return; + } + } + if (self.leaflet.currentPopupRequest !== popupRequest) { + return; + } + const popupConfigInput = self.config.mapOptions.nodePopup.config || {}; + const popupConfig = Object.fromEntries( + Object.entries(popupConfigInput).filter(([, value]) => value != null), + ); + + const popup = window.L.popup({ + ...popupConfig, + }) + .setLatLng(nodeLocation) + .setContent(popupContent); + const popupNodeId = node && node.id != null ? String(node.id) : null; + + popup.on("remove", () => { + if (self.leaflet.currentPopup !== popup) { + return; + } + // Restore the chart's own tooltip (we hid it while the popup was open). + self.utils.setTooltipVisibility(self, true); + self.utils.updateLabelVisibility(self, true); + if ( + self.config.bookmarkableActions && + self.config.bookmarkableActions.enabled && + popupNodeId + ) { + const fragments = self.utils.parseUrlFragments(); + const currentFragment = fragments[bookmarkableActionId]; + if (currentFragment && currentFragment.get("nodeId") === popupNodeId) { + self.utils.removeUrlFragment(bookmarkableActionId, "nodeId"); + } + } + self.leaflet.currentPopup = null; + if (self.leaflet.currentPopupRequest === popupRequest) { + self.leaflet.currentPopupRequest = null; + } + }); + + self.leaflet.currentPopup = popup; + popup.openOn(self.leaflet); + // Hide tooltip and labels while the popup is open + self.utils.setTooltipVisibility(self, false); + self.utils.updateLabelVisibility(self, false); + + const {onOpen} = self.config.mapOptions.nodePopup; + if (typeof onOpen === "function") { + try { + onOpen.call(self); + } catch (error) { + // onOpen runs after the popup is already visible. If it throws we + // roll back to a consistent state by removing the popup; the popup's + // own remove handler clears the URL fragment when it still points + // at the now-rolled-back node. + if (self.leaflet.currentPopup) { + self.leaflet.currentPopup.remove(); + } + console.error("Failed to run popup onOpen callback:", error); + } + } + } + + /** + * Build the default popup body for a node: a `.default-popup` div with + * `name`, `id`, `label`, and `location` rows (rendered only when present). + * All values are written with `textContent`, so they are XSS-safe even if + * the node fields contain user-controlled strings. + * + * Consumers may call this from a custom `mapOptions.nodePopup.content` + * function and extend the returned element with additional UI. + * + * @param {Object} node + * @returns {HTMLElement} + */ + createDefaultPopupContent(node) { + const popupContent = document.createElement("div"); + popupContent.classList.add("default-popup"); + const location = this.getNodeLocation(node); + const lat = Number(location?.lat); + const lng = Number(location?.lng); + const hasCoords = Number.isFinite(lat) && Number.isFinite(lng); + const fields = { + name: node?.name, + id: node?.id, + label: node?.label, + location: hasCoords ? `${lat.toFixed(8)}, ${lng.toFixed(8)}` : null, + }; + Object.keys(fields).forEach((key) => { + const value = fields[key]; + // Skip only null/undefined/empty-string; preserve falsy-but-valid + // values like id: 0. + if (value == null || value === "") { + return; + } + const item = document.createElement("div"); + item.classList.add("njg-tooltip-item"); + const keyLabel = document.createElement("span"); + keyLabel.classList.add("njg-tooltip-key"); + keyLabel.textContent = key; + const valueLabel = document.createElement("span"); + valueLabel.classList.add("njg-tooltip-value"); + valueLabel.textContent = String(value); + item.appendChild(keyLabel); + item.appendChild(valueLabel); + popupContent.appendChild(item); + }); + return popupContent; + } + init() { this.sideBar = this.createSideBar(); if (this.self.config.switchMode) { diff --git a/src/js/netjsongraph.render.js b/src/js/netjsongraph.render.js index 1a4635bc..2eaadc54 100644 --- a/src/js/netjsongraph.render.js +++ b/src/js/netjsongraph.render.js @@ -103,14 +103,19 @@ class NetJSONGraphRender { const clickElement = configs.onClickElement.bind(self); self.utils.addActionToUrl(self, params); if (params.componentSubType === "graph") { - return clickElement( - params.dataType === "edge" ? "link" : "node", - params.data, - ); + clickElement(params.dataType === "edge" ? "link" : "node", params.data); + return; + } + if (params.componentSubType === "lines") { + clickElement("link", params.data.link); + return; + } + if (params.data && !params.data.cluster) { + if (configs.mapOptions?.nodePopup?.show) { + self.gui.loadNodePopup(params.data.node); + } + clickElement("node", params.data.node); } - return params.componentSubType === "lines" - ? clickElement("link", params.data.link) - : !params.data.cluster && clickElement("node", params.data.node); }, {passive: true}, ); @@ -598,33 +603,10 @@ class NetJSONGraphRender { self.leaflet.fitBounds(bounds, {padding: [20, 20]}); } } - - const updateLabelVisibility = () => { - const showLabel = - self.config.showMapLabelsAtZoom !== false && - self.leaflet.getZoom() >= self.config.showMapLabelsAtZoom; - self.echarts.setOption({ - series: [ - { - id: "geo-map", - label: { - show: showLabel, - silent: true, - }, - emphasis: { - label: { - show: false, - }, - }, - }, - ], - }); - }; - - updateLabelVisibility(); + self.utils.updateLabelVisibility(self, true); self.leaflet.on("zoomend", () => { - updateLabelVisibility(); + self.utils.updateLabelVisibility(self, true); // Zoom in/out buttons disabled only when it is equal to min/max zoomlevel // Manually handle zoom control state to ensure correct behavior with float zoom levels const currentZoom = self.leaflet.getZoom(); @@ -722,7 +704,7 @@ class NetJSONGraphRender { clusters, ), ); - updateLabelVisibility(); + self.utils.updateLabelVisibility(self, true); self.echarts.on("click", (params) => { if ( @@ -762,7 +744,7 @@ class NetJSONGraphRender { // When above the threshold, show all nodes without clustering self.echarts.setOption(self.utils.generateMapOption(JSONData, self)); } - updateLabelVisibility(); + self.utils.updateLabelVisibility(self, true); }); } diff --git a/src/js/netjsongraph.util.js b/src/js/netjsongraph.util.js index af585f39..8518cb3d 100644 --- a/src/js/netjsongraph.util.js +++ b/src/js/netjsongraph.util.js @@ -1328,9 +1328,29 @@ class NetJSONGraphUtil { this.updateUrlFragments(fragments, nodeData); } - removeUrlFragment(id) { + /** + * Remove the URL fragment for the given action id, or just one URLSearchParams + * key inside that fragment. + * + * @param {string} id The bookmarkable action id (e.g. "geoMap"). + * @param {string} [paramName] If provided, only this query-param is removed + * from the fragment. If omitted, the whole fragment for the id is dropped. + */ + removeUrlFragment(id, paramName = null) { const fragments = this.parseUrlFragments(); - if (fragments[id]) { + if (!fragments[id]) { + return; + } + if (paramName) { + fragments[id].delete(paramName); + // Drop the whole entry if only the bare action id is left — a fragment + // like "#id=geoMap" with no other params is a useless stub that + // parseUrlFragments would still pick up on subsequent visits. + const remainingKeys = Array.from(fragments[id].keys()).filter((k) => k !== "id"); + if (remainingKeys.length === 0) { + delete fragments[id]; + } + } else { delete fragments[id]; } const state = {id}; @@ -1347,6 +1367,11 @@ class NetJSONGraphUtil { const nodeId = fragmentParams && fragmentParams.get ? fragmentParams.get("nodeId") : undefined; if (!nodeId || !self.nodeLinkIndex || self.nodeLinkIndex[nodeId] == null) { + // Popstate to a state without a selected node: close any open popup so + // the visible map state matches the URL. + if (self.leaflet && self.leaflet.currentPopup) { + self.leaflet.currentPopup.remove(); + } return; } const [source, target] = nodeId.split("~"); @@ -1381,6 +1406,9 @@ class NetJSONGraphUtil { self.leaflet.setView(center, zoom); } } + if (target == null && self.config.mapOptions?.nodePopup?.show) { + self.gui.loadNodePopup(node); + } if (typeof self.config.onClickElement === "function") { self.config.onClickElement.call(self, source && target ? "link" : "node", node); } @@ -1441,6 +1469,43 @@ class NetJSONGraphUtil { series: options.series, }); } + + updateLabelVisibility(self, show) { + if (!self.echarts || typeof self.echarts.setOption !== "function") { + console.warn("updateLabelVisibility: ECharts instance not ready"); + return; + } + const showLabel = + show && + self.config.showMapLabelsAtZoom !== false && + self.leaflet.getZoom() >= self.config.showMapLabelsAtZoom; + self.echarts.setOption({ + series: [ + { + id: "geo-map", + label: { + show: showLabel, + silent: true, + }, + emphasis: { + label: { + show: false, + }, + }, + }, + ], + }); + } + + /** + * Hide/show the rendered ECharts tooltip without changing user tooltip config. + */ + setTooltipVisibility(self, visible) { + if (!self.el) { + return; + } + self.el.classList.toggle("njg-hide-tooltip", !visible); + } } export default NetJSONGraphUtil; diff --git a/test/browser.test.utils.js b/test/browser.test.utils.js index 14cbb504..f7786ccc 100644 --- a/test/browser.test.utils.js +++ b/test/browser.test.utils.js @@ -132,8 +132,31 @@ export const getPresentNodesAndLinksCount = async (example) => { export const captureConsoleErrors = async (driver) => { const logs = await driver.manage().logs().get("browser"); + // OSM tile 503s are upstream rate-limiting flakes — filter them so CI + // does not red on unrelated infrastructure issues. + const isIgnoredNoise = (msg) => { + if (msg.includes("favicon.ico")) { + return true; + } + if (!msg.includes("503")) { + return false; + } + const urlMatch = msg.match(/https?:\/\/[^\s"')]+/); + if (!urlMatch) { + return false; + } + try { + const {hostname} = new URL(urlMatch[0]); + return ( + hostname === "tile.openstreetmap.org" || + hostname.endsWith(".tile.openstreetmap.org") + ); + } catch (error) { + return false; + } + }; return logs.filter( - (log) => log.level.name === "SEVERE" && !log.message.includes("favicon.ico"), + (log) => log.level.name === "SEVERE" && !isIgnoredNoise(log.message), ); }; diff --git a/test/netjsongraph.browser.test.js b/test/netjsongraph.browser.test.js index ac226382..c51a1b0c 100644 --- a/test/netjsongraph.browser.test.js +++ b/test/netjsongraph.browser.test.js @@ -169,8 +169,12 @@ describe("Chart Rendering Test", () => { "//span[@class='njg-valueLabel' and text()='10.149.3.3']", 2000, ); - const nodeId = await node.getText(); - + // Use textContent rather than getText(): in the popup-based examples the + // sidebar is hidden via CSS, and Selenium's getText() returns "" for + // elements that aren't visible. textContent gives us the raw text + // regardless of layout state, so all the bookmarkable tests stay + // consistent across visible-sidebar and hidden-sidebar examples. + const nodeId = await node.getAttribute("textContent"); printConsoleErrors(consoleErrors); expect(consoleErrors.length).toBe(0); expect(canvas).not.toBeNull(); @@ -195,8 +199,8 @@ describe("Chart Rendering Test", () => { "//span[@class='njg-valueLabel' and text()='172.16.155.4']", 2000, ); - const sourceId = await source.getText(); - const targetId = await target.getText(); + const sourceId = await source.getAttribute("textContent"); + const targetId = await target.getAttribute("textContent"); printConsoleErrors(consoleErrors); expect(consoleErrors.length).toBe(0); @@ -216,7 +220,7 @@ describe("Chart Rendering Test", () => { "//span[@class='njg-valueLabel' and text()='172.16.169.1']", 2000, ); - const nodeId = await node.getText(); + const nodeId = await node.getAttribute("textContent"); printConsoleErrors(consoleErrors); expect(consoleErrors.length).toBe(0); @@ -242,8 +246,8 @@ describe("Chart Rendering Test", () => { "//span[@class='njg-valueLabel' and text()='172.16.185.13']", 2000, ); - const sourceId = await source.getText(); - const targetId = await target.getText(); + const sourceId = await source.getAttribute("textContent"); + const targetId = await target.getAttribute("textContent"); printConsoleErrors(consoleErrors); expect(consoleErrors.length).toBe(0); @@ -269,6 +273,12 @@ describe("Chart Rendering Test", () => { await driver.executeScript('window._geoMap.utils.triggerOnClick("172.16.171.15");'); let currentUrl = await driver.getCurrentUrl(); expect(currentUrl).toContain("172.16.171.15"); + const floorplanBtn = await getElementByCss(driver, ".njg-popup-button", 2000); + expect(floorplanBtn).not.toBeNull(); + // Use JS click to avoid Leaflet popup click interception in Chrome + await driver.executeScript("arguments[0].click();", floorplanBtn); + // wait for overlay to open and render indoor map + await driver.sleep(500); let indoorContainer = await getElementByCss(driver, "#indoormap-container", 2000); const indoorCanvas = await getElementByCss(driver, "canvas", 2000); const floorplanImage = await getElementByCss(driver, ".leaflet-image-layer", 2000); @@ -297,14 +307,41 @@ describe("Chart Rendering Test", () => { test("bookmarkableActions: test url fragments for nodes", async () => { await driver.get(`${urls.indoorMapOverlay}#id=geoMap&nodeId=172.16.177.33`); const canvas = await getElementByCss(driver, "canvas", 2000); - const indoorContainer = await getElementByCss(driver, "#indoormap-container", 2000); - const floorplanImage = await getElementByCss(driver, ".leaflet-image-layer", 2000); + const indoorContainer = await getElementByCss(driver, ".njg-popup-button", 2000); + const node = await getElementByXpath( + driver, + "//span[@class='njg-tooltip-value' and text()='172.16.177.33']", + 2000, + ); + const nodeId = await node.getAttribute("textContent"); const consoleErrors = await captureConsoleErrors(driver); printConsoleErrors(consoleErrors); expect(consoleErrors.length).toBe(0); expect(canvas).not.toBeNull(); expect(indoorContainer).not.toBeNull(); - expect(floorplanImage).not.toBeNull(); + expect(nodeId).toBe("172.16.177.33"); + }); + + test("nodePopup toggles tooltip suppression while popup is open", async () => { + await driver.get(urls.indoorMapOverlay); + const canvas = await getElementByCss(driver, "canvas", 2000); + expect(canvas).not.toBeNull(); + await driver.executeScript('window._geoMap.utils.triggerOnClick("172.16.171.15");'); + const popupCloseBtn = await getElementByCss( + driver, + ".leaflet-popup-close-button", + 2000, + ); + expect(popupCloseBtn).not.toBeNull(); + let tooltipSuppressed = await driver.executeScript( + 'return window._geoMap.el.classList.contains("njg-hide-tooltip");', + ); + expect(tooltipSuppressed).toBe(true); + await driver.executeScript("arguments[0].click();", popupCloseBtn); + tooltipSuppressed = await driver.executeScript( + 'return window._geoMap.el.classList.contains("njg-hide-tooltip");', + ); + expect(tooltipSuppressed).toBe(false); }); test("bookmarkableActions: test forward/backward actions", async () => { @@ -314,12 +351,25 @@ describe("Chart Rendering Test", () => { await driver.executeScript('window._geoMap.utils.triggerOnClick("172.16.171.15");'); let currentUrl = await driver.getCurrentUrl(); expect(currentUrl).toContain("172.16.171.15"); - let indoorContainer = await getElementByCss(driver, "#indoormap-container"); + let node = await getElementByXpath( + driver, + "//span[@class='njg-tooltip-value' and text()='172.16.171.15']", + 2000, + ); + let nodeId = await node.getAttribute("textContent"); + expect(nodeId).toBe("172.16.171.15"); + const floorplanBtn = await getElementByCss(driver, ".njg-popup-button", 2000); + expect(floorplanBtn).not.toBeNull(); + await driver.executeScript("arguments[0].click();", floorplanBtn); + // wait for overlay to open and render indoor map + await driver.sleep(500); + let indoorContainer = await getElementByCss(driver, "#indoormap-container", 2000); expect(indoorContainer).not.toBeNull(); await driver.executeScript('window._indoorMap.utils.triggerOnClick("node_2");'); currentUrl = await driver.getCurrentUrl(); expect(currentUrl).toContain("node_2"); await driver.get("http://0.0.0.0:8080"); + await driver.sleep(500); await driver.navigate().back(); await driver.sleep(500); currentUrl = await driver.getCurrentUrl(); @@ -327,9 +377,13 @@ describe("Chart Rendering Test", () => { expect(currentUrl).toContain("node_2"); indoorContainer = await getElementByCss(driver, "#indoormap-container"); expect(indoorContainer).not.toBeNull(); - let node = await getElementByCss(driver, "#indoormap-container .njg-valueLabel"); - let nodeId = await node.getText(); - expect(nodeId).toBe("Node_2"); + node = await getElementByXpath( + driver, + "//span[@class='njg-tooltip-value' and text()='Node 2']", + 2000, + ); + nodeId = await node.getAttribute("textContent"); + expect(nodeId).toBe("Node 2"); await driver.navigate().back(); await driver.sleep(500); currentUrl = await driver.getCurrentUrl(); @@ -337,6 +391,13 @@ describe("Chart Rendering Test", () => { expect(currentUrl).not.toContain("node_2"); indoorContainer = await getElementByCss(driver, "#indoormap-container"); expect(indoorContainer).toBeNull(); + node = await getElementByXpath( + driver, + "//span[@class='njg-tooltip-value' and text()='172.16.171.15']", + 2000, + ); + nodeId = await node.getAttribute("textContent"); + expect(nodeId).toBe("172.16.171.15"); await driver.navigate().forward(); await driver.sleep(500); currentUrl = await driver.getCurrentUrl(); @@ -344,13 +405,17 @@ describe("Chart Rendering Test", () => { expect(currentUrl).toContain("node_2"); indoorContainer = await getElementByCss(driver, "#indoormap-container"); expect(indoorContainer).not.toBeNull(); - node = await getElementByCss(driver, "#indoormap-container .njg-valueLabel"); - nodeId = await node.getText(); - expect(nodeId).toBe("Node_2"); + node = await getElementByXpath( + driver, + "//span[@class='njg-tooltip-value' and text()='Node 2']", + 2000, + ); + nodeId = await node.getAttribute("textContent"); + expect(nodeId).toBe("Node 2"); const consoleErrors = await captureConsoleErrors(driver); printConsoleErrors(consoleErrors); expect(consoleErrors.length).toBe(0); - }); + }, 10000); // This test needs more time test("bookmarkableActions: check if parseUrlFragments handles invalid UTF-8", async () => { // Invalid UTF-8 sequence in hash @@ -424,7 +489,7 @@ describe("Chart Rendering Test", () => { expect(canvas).not.toBeNull(); // Wait for map to initialize and get initial position - await driver.sleep(2000); + await driver.sleep(500); const initialPosition = await driver.executeScript(() => { const options = window.map.echarts.getOption(); const series = options.series.find((s) => s.type === "scatter"); diff --git a/test/netjsongraph.dom.test.js b/test/netjsongraph.dom.test.js index 964982eb..5deb6e71 100644 --- a/test/netjsongraph.dom.test.js +++ b/test/netjsongraph.dom.test.js @@ -442,3 +442,655 @@ describe("Test GUI on narrow screens", () => { expect(graph.gui.nodeLinkInfoContainer.innerHTML).toContain("region"); }); }); + +describe("Test GUI createDefaultPopupContent", () => { + beforeEach(() => { + graph.gui = new NetJSONGraphGUI(graph); + }); + afterEach(() => { + graph.gui = null; + }); + test("Create default popup content with valid location coordinates", () => { + const node = { + id: "node-1", + name: "Test Node", + label: "Node Label", + location: { + lat: 12.3456789, + lng: 98.7654321, + }, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.classList.contains("default-popup")).toBe(true); + expect(content.innerHTML).toContain("node-1"); + expect(content.innerHTML).toContain("Test Node"); + expect(content.innerHTML).toContain("Node Label"); + expect(content.innerHTML).toContain("12.34567890"); + expect(content.innerHTML).toContain("98.76543210"); + }); + + test("Create default popup content with missing location should not display coordinates", () => { + const node = { + id: "node-2", + name: "Test Node No Location", + label: "No Location Node", + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.innerHTML).toContain("node-2"); + expect(content.innerHTML).not.toContain("location"); + }); + + test("Create default popup content with null location should not display coordinates", () => { + const node = { + id: "node-3", + name: "Test Node", + label: "Node Label", + location: null, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.innerHTML).toContain("node-3"); + expect(content.innerHTML).not.toContain("location"); + }); + + test("Create default popup content with NaN coordinates should not display coordinates", () => { + const node = { + id: "node-4", + name: "Test Node", + label: "Node Label", + location: { + lat: NaN, + lng: 98.7654321, + }, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.innerHTML).toContain("node-4"); + expect(content.innerHTML).not.toContain("location"); + }); + + test("Create default popup content with Infinity coordinates should not display coordinates", () => { + const node = { + id: "node-5", + name: "Test Node", + label: "Node Label", + location: { + lat: Infinity, + lng: 98.7654321, + }, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.innerHTML).toContain("node-5"); + expect(content.innerHTML).not.toContain("location"); + }); + + test("Create default popup content with string coordinates should convert and validate", () => { + const node = { + id: "node-6", + name: "Test Node", + label: "Node Label", + location: { + lat: "45.123456", + lng: "-87.654321", + }, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.innerHTML).toContain("45.12345600"); + expect(content.innerHTML).toContain("-87.65432100"); + }); + + test("Create default popup content with properties.location fallback", () => { + const node = { + id: "node-7", + name: "Test Node", + label: "Node Label", + properties: { + location: { + lat: 10.5, + lng: 20.5, + }, + }, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.innerHTML).toContain("10.50000000"); + expect(content.innerHTML).toContain("20.50000000"); + }); + + test("Create default popup content with only finite lat should not display coordinates", () => { + const node = { + id: "node-8", + name: "Test Node", + label: "Node Label", + location: { + lat: 45.123, + lng: NaN, + }, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.innerHTML).not.toContain("location"); + }); + + test("Create default popup content with empty node", () => { + const node = {}; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + expect(content.classList.contains("default-popup")).toBe(true); + }); + + test("Create default popup content prefers properties.location over top-level location", () => { + // loadNodePopup positions the popup using node.properties.location || + // node.location. createDefaultPopupContent must read coordinates in the + // same order, otherwise the rendered text would disagree with the popup + // position when both fields exist with different values. + const node = { + id: "node-mismatch", + location: {lat: 1, lng: 2}, + properties: {location: {lat: 3, lng: 4}}, + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content.innerHTML).toContain("3.00000000"); + expect(content.innerHTML).toContain("4.00000000"); + expect(content.innerHTML).not.toContain("1.00000000"); + expect(content.innerHTML).not.toContain("2.00000000"); + }); + + test("Create default popup content keeps falsy-but-valid id (id:0)", () => { + // id:0 is a legal integer NetJSON node id and must not be filtered out. + const node = { + id: 0, + name: "Origin node", + label: "Origin", + }; + const content = graph.gui.createDefaultPopupContent(node); + expect(content).toBeInstanceOf(HTMLElement); + const items = content.querySelectorAll(".njg-tooltip-item"); + const idItem = Array.from(items).find( + (el) => el.querySelector(".njg-tooltip-key")?.textContent === "id", + ); + expect(idItem).toBeDefined(); + expect(idItem.querySelector(".njg-tooltip-value").textContent).toBe("0"); + }); +}); + +describe("Test GUI loadNodePopup with async and tooltip handling", () => { + let testGraph; + let container; + let originalLeaflet; + let mockPopup; + + const mockLeafletPopup = (popupElement) => { + mockPopup = { + getElement: jest.fn(() => popupElement), + setLatLng: jest.fn(() => mockPopup), + setContent: jest.fn(() => mockPopup), + openOn: jest.fn(() => mockPopup), + handlers: {}, + on: jest.fn((event, handler) => { + mockPopup.handlers[event] = handler; + return mockPopup; + }), + remove: jest.fn(), + }; + window.L = { + CRS: { + EPSG3857: {}, + }, + popup: jest.fn(() => mockPopup), + }; + global.L = window.L; + }; + + beforeEach(() => { + originalLeaflet = window.L; + mockLeafletPopup({ + querySelector: jest.fn(), + }); + container = document.createElement("div"); + container.setAttribute("id", "test-popup-map"); + document.body.appendChild(container); + testGraph = new NetJSONGraphCore({ + nodes: [{id: "node-1", location: {lat: 10, lng: 20}}], + links: [], + }); + testGraph.event = testGraph.utils.createEvent(); + testGraph.gui = new NetJSONGraphGUI(testGraph); + testGraph.setConfig({ + el: container, + mapOptions: { + nodePopup: { + show: true, + content: null, + config: {autoPan: true}, + }, + }, + bookmarkableActions: { + enabled: true, + id: "id", + }, + }); + testGraph.setUtils(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (container && document.body.contains(container)) { + document.body.removeChild(container); + } + window.L = originalLeaflet; + global.L = originalLeaflet; + testGraph = null; + }); + + test("loadNodePopup hides tooltip on popup open", async () => { + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.setTooltipVisibility = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, false); + expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith( + testGraph, + false, + ); + }); + + test("loadNodePopup handles async content error and cleans up URL fragment", async () => { + const asyncContentHandler = jest.fn(() => + Promise.reject(new Error("Content load failed")), + ); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: asyncContentHandler, + config: {autoPan: true}, + }, + }, + bookmarkableActions: { + enabled: true, + id: "id", + }, + }); + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + currentPopupRequest: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.removeUrlFragment = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId"); + expect(testGraph.echarts.setOption).not.toHaveBeenCalled(); + expect(testGraph.utils.updateLabelVisibility).not.toHaveBeenCalled(); + }); + + test("loadNodePopup restores tooltip and labels when replacement content fails", async () => { + // Replacement path: a previous popup was open (tooltip already hidden), + // we null currentPopup and call previousPopup.remove() so its handler + // bails. If new content generation then fails the catch must restore + // the tooltip/labels — otherwise the map is stuck in popup-open visual + // state with no popup actually visible. + const asyncContentHandler = jest.fn(() => + Promise.reject(new Error("Replacement content failed")), + ); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: asyncContentHandler, + config: {autoPan: true}, + }, + }, + bookmarkableActions: { + enabled: true, + id: "id", + }, + }); + testGraph.echarts = {setOption: jest.fn()}; + testGraph.leaflet = { + currentPopup: {remove: jest.fn()}, + currentPopupRequest: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.setTooltipVisibility = jest.fn(); + testGraph.utils.removeUrlFragment = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, true); + expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith(testGraph, true); + expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId"); + }); + + test("loadNodePopup catches synchronous custom content errors", async () => { + const contentHandler = jest.fn(() => { + throw new Error("Content build failed"); + }); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: contentHandler, + config: {autoPan: true}, + }, + }, + }); + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + currentPopupRequest: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.removeUrlFragment = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId"); + expect(testGraph.echarts.setOption).not.toHaveBeenCalled(); + }); + + test("loadNodePopup restores tooltip and removes matching fragment on popup remove", async () => { + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.setTooltipVisibility = jest.fn(); + testGraph.utils.parseUrlFragments = jest.fn(() => ({ + id: new URLSearchParams("id=id&nodeId=node-1"), + })); + testGraph.utils.removeUrlFragment = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + expect(mockPopup.on).toHaveBeenCalledWith("remove", expect.any(Function)); + mockPopup.handlers.remove(); + expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, true); + expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith(testGraph, true); + expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId"); + }); + + test("loadNodePopup ignores stale async content without clearing newer URL fragment", async () => { + let resolveFirst; + const asyncContentHandler = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce("
Second Popup
"); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: asyncContentHandler, + config: {autoPan: true}, + }, + }, + }); + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + currentPopupRequest: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.removeUrlFragment = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + const firstRequest = testGraph.gui.loadNodePopup(node); + await testGraph.gui.loadNodePopup(node); + resolveFirst("
First Popup
"); + await firstRequest; + expect(testGraph.utils.removeUrlFragment).not.toHaveBeenCalled(); + expect(mockPopup.setContent).toHaveBeenCalledWith("
Second Popup
"); + }); + + test("loadNodePopup closes the current popup before waiting for async content", async () => { + let resolveContent; + const asyncContentHandler = jest.fn( + () => + new Promise((resolve) => { + resolveContent = resolve; + }), + ); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: asyncContentHandler, + config: {autoPan: true}, + }, + }, + }); + const currentPopup = {remove: jest.fn()}; + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup, + currentPopupRequest: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + const popupRequest = testGraph.gui.loadNodePopup(node); + expect(currentPopup.remove).toHaveBeenCalled(); + resolveContent("
New Popup
"); + await popupRequest; + }); + + test("loadNodePopup handles null popup element gracefully", async () => { + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + mockLeafletPopup(null); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await expect(testGraph.gui.loadNodePopup(node)).resolves.toBeUndefined(); + }); + + test("loadNodePopup with async custom content handler that succeeds", async () => { + const customContent = "
Custom Popup
"; + const asyncContentHandler = jest.fn(() => Promise.resolve(customContent)); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: asyncContentHandler, + config: {autoPan: true}, + }, + }, + }); + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + currentPopupRequest: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + // Verify async content was resolved and popup was created with it. + // Callback receives the netjsongraph instance as `this` (project-wide + // callback convention) and the node as the positional argument. + expect(asyncContentHandler).toHaveBeenCalledWith(node); + expect(asyncContentHandler.mock.instances[0]).toBe(testGraph); + expect(mockPopup.setContent).toHaveBeenCalledWith(customContent); + }); + + test("loadNodePopup calls onOpen callback if provided", async () => { + const onOpenCallback = jest.fn(); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: null, + config: {autoPan: true}, + onOpen: onOpenCallback, + }, + }, + }); + testGraph.echarts = { + setOption: jest.fn(), + }; + testGraph.leaflet = { + currentPopup: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.removeUrlFragment = jest.fn(); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + // Verify onOpen callback was called with the netjsongraph instance as + // `this` (project-wide callback convention) and no positional args. + expect(onOpenCallback).toHaveBeenCalledWith(); + expect(onOpenCallback.mock.instances[0]).toBe(testGraph); + }); + + test("loadNodePopup closes the popup and clears URL when onOpen throws", async () => { + // The popup is already visible by the time onOpen runs (openOn happens + // before onOpen is invoked). If onOpen throws, both the URL fragment and + // the popup must be rolled back so the visible map state matches the URL. + const onOpenError = new Error("onOpen failed"); + const onOpenCallback = jest.fn(() => { + throw onOpenError; + }); + testGraph.setConfig({ + mapOptions: { + nodePopup: { + show: true, + content: null, + config: {autoPan: true}, + onOpen: onOpenCallback, + }, + }, + bookmarkableActions: { + enabled: true, + id: "id", + }, + }); + testGraph.echarts = {setOption: jest.fn()}; + testGraph.leaflet = { + currentPopup: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.parseUrlFragments = jest.fn(() => ({ + id: new URLSearchParams("id=id&nodeId=node-1"), + })); + testGraph.utils.removeUrlFragment = jest.fn(); + // Match real Leaflet: .remove() fires the registered "remove" handler + // synchronously. This lets the assertions confirm both that the popup + // got removed AND that the handler then cleared the URL fragment. + mockPopup.remove = jest.fn(() => { + if (typeof mockPopup.handlers.remove === "function") { + mockPopup.handlers.remove(); + } + }); + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + expect(mockPopup.remove).toHaveBeenCalled(); + expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId"); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Failed to run popup onOpen callback:", + onOpenError, + ); + }); + + test("loadNodePopup preserves URL fragment when replacing popup for the still-current node", async () => { + // Reproduces the popstate-restoration regression: when the URL still + // points to the currently-open node and applyUrlFragmentState re-invokes + // loadNodePopup for that same node, removing the previous popup must + // not trigger the user-close cleanup that strips the URL fragment. + const popups = []; + const makePopup = () => { + const popup = { + setLatLng: jest.fn(() => popup), + setContent: jest.fn(() => popup), + openOn: jest.fn(() => popup), + handlers: {}, + on: jest.fn((event, handler) => { + popup.handlers[event] = handler; + return popup; + }), + remove: jest.fn(() => { + // Match real Leaflet: firing remove() invokes the "remove" event + // listener synchronously. + if (typeof popup.handlers.remove === "function") { + popup.handlers.remove(); + } + }), + }; + popups.push(popup); + return popup; + }; + window.L = {CRS: {EPSG3857: {}}, popup: jest.fn(makePopup)}; + global.L = window.L; + + testGraph.echarts = {setOption: jest.fn()}; + testGraph.leaflet = { + currentPopup: null, + currentPopupRequest: null, + once: jest.fn(), + off: jest.fn(), + }; + testGraph.utils.updateLabelVisibility = jest.fn(); + testGraph.utils.parseUrlFragments = jest.fn(() => ({ + id: new URLSearchParams("id=id&nodeId=node-1"), + })); + testGraph.utils.removeUrlFragment = jest.fn(); + + const node = {id: "node-1", location: {lat: 10, lng: 20}}; + await testGraph.gui.loadNodePopup(node); + expect(popups).toHaveLength(1); + + // Second invocation for the same node (popstate restoration path). + await testGraph.gui.loadNodePopup(node); + expect(popups).toHaveLength(2); + expect(popups[0].remove).toHaveBeenCalled(); + + expect(testGraph.utils.removeUrlFragment).not.toHaveBeenCalled(); + }); +}); diff --git a/test/netjsongraph.render.test.js b/test/netjsongraph.render.test.js index 1bbda4c4..d8088b90 100644 --- a/test/netjsongraph.render.test.js +++ b/test/netjsongraph.render.test.js @@ -1,6 +1,11 @@ import L from "leaflet"; import NetJSONGraphCore from "../src/js/netjsongraph.core"; import NetJSONGraphRender from "../src/js/netjsongraph.render"; +import NetJSONGraphGUI from "../src/js/netjsongraph.gui"; +import NetJSONGraphUtil from "../src/js/netjsongraph.util"; + +const createUpdateLabelVisibilityMock = () => + jest.fn((self, show) => new NetJSONGraphUtil().updateLabelVisibility(self, show)); const JSONFILE = "test"; const JSONData = { @@ -512,6 +517,7 @@ describe("generateMapOption - node processing and dynamic styling", () => { })), parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, }; }); @@ -997,6 +1003,7 @@ describe("Test disableClusteringAtLevel: 0", () => { fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), setupHashChangeHandler: jest.fn(), parseUrlFragments: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, event: { emit: jest.fn(), @@ -1097,6 +1104,7 @@ describe("Test leaflet zoomend handler and zoom control state", () => { fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, event: { emit: jest.fn(), @@ -1242,6 +1250,7 @@ describe("mapRender – polygon overlay & moveend bbox logic", () => { fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, event: {emit: jest.fn()}, }; @@ -1307,6 +1316,7 @@ describe("graph label visibility and fallbacks", () => { fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, echarts: { getOption: jest.fn(() => ({series: [{id: "network-graph", zoom: 1}]})), @@ -1345,6 +1355,7 @@ describe("graph label visibility and fallbacks", () => { fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, echarts: { getOption: jest @@ -1377,6 +1388,7 @@ describe("graph label visibility and fallbacks", () => { parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), _propagateGraphZoom: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, echarts: { on: jest.fn((evt, cb) => { @@ -1423,6 +1435,7 @@ describe("map series ids and name fallbacks", () => { fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, }; @@ -1489,6 +1502,7 @@ describe("map series ids and name fallbacks", () => { fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), parseUrlFragments: jest.fn(), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, event: {emit: jest.fn()}, }; @@ -1555,6 +1569,7 @@ describe("mapRender label and tooltip interaction (emphasis behavior)", () => { })), echartsSetOption: jest.fn((opt) => mockSelf.echarts.setOption(opt)), // Link to spy setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, event: {emit: jest.fn()}, }; @@ -1763,6 +1778,7 @@ describe("mapRender clustering label visibility", () => { nonClusterLinks: [], })), setupHashChangeHandler: jest.fn(), + updateLabelVisibility: createUpdateLabelVisibilityMock(), }, event: {emit: jest.fn()}, }; @@ -1819,3 +1835,139 @@ describe("mapRender clustering label visibility", () => { expect(series.emphasis.label.show).toBe(false); }); }); + +describe("Test nodePopup on node and link click", () => { + test("nodePopup config with loadNodePopup method exists", () => { + const nodePopupTestData = { + nodes: [ + { + id: "node-1", + location: {lat: 10, lng: 20}, + }, + ], + links: [], + }; + const container = document.createElement("div"); + container.setAttribute("id", "map"); + document.body.appendChild(container); + const popupGraph = new NetJSONGraphCore(nodePopupTestData); + popupGraph.event = popupGraph.utils.createEvent(); + popupGraph.setConfig({ + el: container, + mapOptions: { + nodePopup: { + show: true, + content: null, + config: { + autoPan: true, + }, + }, + }, + onClickElement: jest.fn(), + }); + popupGraph.setUtils(); + popupGraph.gui = new NetJSONGraphGUI(popupGraph); + expect(popupGraph.config.mapOptions.nodePopup).toBeDefined(); + expect(popupGraph.config.mapOptions.nodePopup.show).toBe(true); + expect(popupGraph.config.mapOptions.nodePopup.config.autoPan).toBe(true); + expect(popupGraph.gui).toBeDefined(); + expect(popupGraph.gui.loadNodePopup).toBeInstanceOf(Function); + document.body.removeChild(container); + }); + + test("nodePopup disabled in config", () => { + const nodePopupTestData2 = { + nodes: [ + { + id: "node-1", + location: {lat: 10, lng: 20}, + }, + ], + links: [], + }; + const container = document.createElement("div"); + container.setAttribute("id", "map-2"); + document.body.appendChild(container); + const disabledPopupGraph = new NetJSONGraphCore(nodePopupTestData2); + disabledPopupGraph.event = disabledPopupGraph.utils.createEvent(); + disabledPopupGraph.setConfig({ + el: container, + mapOptions: { + nodePopup: { + show: false, + }, + }, + }); + disabledPopupGraph.setUtils(); + expect(disabledPopupGraph.config.mapOptions.nodePopup.show).toBe(false); + document.body.removeChild(container); + }); + + test("createDefaultPopupContent creates valid HTML", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const defaultPopupGraph = new NetJSONGraphCore({ + type: "NetworkGraph", + nodes: [], + links: [], + }); + defaultPopupGraph.event = defaultPopupGraph.utils.createEvent(); + defaultPopupGraph.gui = new NetJSONGraphGUI(defaultPopupGraph); + const node = { + id: "test-node", + name: "Test Node", + label: "Node Label", + location: { + lat: 45.123456, + lng: -87.654321, + }, + }; + const popupContent = defaultPopupGraph.gui.createDefaultPopupContent(node); + expect(popupContent).toBeInstanceOf(HTMLElement); + expect(popupContent.classList.contains("default-popup")).toBe(true); + expect(popupContent.innerHTML).toContain("test-node"); + expect(popupContent.innerHTML).toContain("Test Node"); + expect(popupContent.innerHTML).toContain("Node Label"); + expect(popupContent.innerHTML).toContain("45.12345600"); + expect(popupContent.innerHTML).toContain("-87.65432100"); + document.body.removeChild(container); + }); + + test("nodePopup configuration includes custom popup content handler", () => { + const nodePopupTestData3 = { + nodes: [{id: "node-1", location: {lat: 10, lng: 20}}], + links: [], + }; + const customContentHandler = jest.fn(); + const onOpenHandler = jest.fn(); + const container = document.createElement("div"); + container.setAttribute("id", "map-3"); + document.body.appendChild(container); + const customPopupGraph = new NetJSONGraphCore(nodePopupTestData3); + customPopupGraph.event = customPopupGraph.utils.createEvent(); + customPopupGraph.setConfig({ + el: container, + mapOptions: { + nodePopup: { + show: true, + content: customContentHandler, + config: { + autoPan: false, + offset: [10, 20], + }, + onOpen: onOpenHandler, + }, + }, + }); + customPopupGraph.setUtils(); + expect(customPopupGraph.config.mapOptions.nodePopup.content).toBe( + customContentHandler, + ); + expect(customPopupGraph.config.mapOptions.nodePopup.onOpen).toBe(onOpenHandler); + expect(customPopupGraph.config.mapOptions.nodePopup.config.autoPan).toBe(false); + expect(customPopupGraph.config.mapOptions.nodePopup.config.offset).toEqual([ + 10, 20, + ]); + document.body.removeChild(container); + }); +}); diff --git a/test/netjsongraph.spec.js b/test/netjsongraph.spec.js index 85a3e324..3bfe9c31 100644 --- a/test/netjsongraph.spec.js +++ b/test/netjsongraph.spec.js @@ -200,6 +200,14 @@ describe("NetJSONGraphCore Specification", () => { }, ], }, + nodePopup: { + show: false, + content: null, + config: { + autoPan: true, + autoPanPadding: [25, 25], + }, + }, }; test("APIs exist", () => { diff --git a/test/netjsongraph.util.test.js b/test/netjsongraph.util.test.js index 4f6eca96..0b60c522 100644 --- a/test/netjsongraph.util.test.js +++ b/test/netjsongraph.util.test.js @@ -314,7 +314,10 @@ describe("Test URL fragment utilities", () => { zoomOnRestore: true, }, graphConfig: {series: {type: null}}, - mapOptions: {nodeConfig: {type: "scatter"}, center: [0, 0]}, + mapOptions: { + nodeConfig: {type: "scatter"}, + center: [0, 0], + }, onClickElement: mockOnClick, }, nodeLinkIndex: {n1: node}, @@ -429,3 +432,456 @@ describe("Test move Node in Real Time", () => { expect(updated.value).toEqual([newLocation.lng, newLocation.lat]); }); }); + +describe("Test applyUrlFragmentState with nodePopup", () => { + test("calls loadNodePopup when target is null and nodePopup.show is true", () => { + const util = new NetJSONGraphUtil(); + const node = { + id: "node-1", + location: {lat: 10, lng: 20}, + }; + const params = new URLSearchParams(); + params.set("id", "id"); + params.set("nodeId", "node-1"); + const fragments = { + id: params, + }; + const mockSelf = { + config: { + bookmarkableActions: { + enabled: true, + id: "id", + zoomOnRestore: false, + }, + mapOptions: { + nodePopup: { + show: true, + }, + }, + onClickElement: jest.fn(), + }, + gui: { + loadNodePopup: jest.fn(), + }, + utils: { + parseUrlFragments: jest.fn(() => fragments), + }, + nodeLinkIndex: { + "node-1": node, + }, + leaflet: { + setView: jest.fn(), + }, + }; + util.applyUrlFragmentState.call(util, mockSelf); + expect(mockSelf.gui.loadNodePopup).toHaveBeenCalledWith(node); + }); + + test("does not call loadNodePopup when target is not null (link case)", () => { + const util = new NetJSONGraphUtil(); + const params = new URLSearchParams(); + params.set("id", "id"); + params.set("nodeId", "node-1~node-2"); + const fragments = { + id: params, + }; + const mockSelf = { + config: { + bookmarkableActions: { + enabled: true, + id: "id", + zoomOnRestore: false, + }, + mapOptions: { + nodePopup: { + show: true, + }, + }, + onClickElement: jest.fn(), + }, + gui: { + loadNodePopup: jest.fn(), + }, + utils: { + parseUrlFragments: jest.fn(() => fragments), + }, + nodeLinkIndex: { + "node-1~node-2": {id: "node-1~node-2", location: {lat: 12, lng: 22}}, + }, + leaflet: { + setView: jest.fn(), + }, + }; + util.applyUrlFragmentState.call(util, mockSelf); + expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled(); + }); + + test("does not call loadNodePopup when nodePopup.show is false", () => { + const util = new NetJSONGraphUtil(); + const node = { + id: "node-3", + location: {lat: 20, lng: 30}, + }; + const params = new URLSearchParams(); + params.set("id", "id"); + params.set("nodeId", "node-3"); + + const fragments = { + id: params, + }; + const mockSelf = { + config: { + bookmarkableActions: { + enabled: true, + id: "id", + zoomOnRestore: false, + }, + mapOptions: { + nodePopup: { + show: false, + }, + }, + onClickElement: jest.fn(), + }, + gui: { + loadNodePopup: jest.fn(), + }, + utils: { + parseUrlFragments: jest.fn(() => fragments), + }, + nodeLinkIndex: { + "node-3": node, + }, + leaflet: { + setView: jest.fn(), + }, + }; + util.applyUrlFragmentState.call(util, mockSelf); + expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled(); + }); + + test("does not call loadNodePopup when mapOptions.nodePopup is not configured", () => { + const util = new NetJSONGraphUtil(); + const node = { + id: "node-4", + location: {lat: 25, lng: 35}, + }; + const params = new URLSearchParams(); + params.set("id", "id"); + params.set("nodeId", "node-4"); + const fragments = { + id: params, + }; + const mockSelf = { + config: { + bookmarkableActions: { + enabled: true, + id: "id", + zoomOnRestore: false, + }, + mapOptions: {}, + onClickElement: jest.fn(), + }, + gui: { + loadNodePopup: jest.fn(), + }, + utils: { + parseUrlFragments: jest.fn(() => fragments), + }, + nodeLinkIndex: { + "node-4": node, + }, + leaflet: { + setView: jest.fn(), + }, + }; + util.applyUrlFragmentState.call(util, mockSelf); + expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled(); + }); + + test("closes the open popup when popstate navigates to a state without nodeId", () => { + // Regression for popstate-back to a no-nodeId state: the URL no longer + // references a selected node, so any popup that was opened by an earlier + // applyUrlFragmentState must be closed to keep the visible state in sync + // with the URL. + const util = new NetJSONGraphUtil(); + const fragments = {}; + const popupRemove = jest.fn(); + const mockSelf = { + config: { + bookmarkableActions: { + enabled: true, + id: "id", + zoomOnRestore: false, + }, + mapOptions: { + nodePopup: { + show: true, + }, + }, + onClickElement: jest.fn(), + }, + gui: { + loadNodePopup: jest.fn(), + }, + utils: { + parseUrlFragments: jest.fn(() => fragments), + }, + nodeLinkIndex: {}, + leaflet: { + setView: jest.fn(), + currentPopup: {remove: popupRemove}, + }, + }; + util.applyUrlFragmentState.call(util, mockSelf); + expect(popupRemove).toHaveBeenCalled(); + expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled(); + }); + + test("calls onClickElement for node clicks regardless of nodePopup setting", () => { + const util = new NetJSONGraphUtil(); + const node = { + id: "node-5", + location: {lat: 30, lng: 40}, + }; + const params = new URLSearchParams(); + params.set("id", "id"); + params.set("nodeId", "node-5"); + const fragments = { + id: params, + }; + const mockSelf = { + config: { + bookmarkableActions: { + enabled: true, + id: "id", + zoomOnRestore: false, + }, + mapOptions: { + nodePopup: { + show: true, + }, + }, + onClickElement: jest.fn(), + }, + gui: { + loadNodePopup: jest.fn(), + }, + utils: { + parseUrlFragments: jest.fn(() => fragments), + }, + nodeLinkIndex: { + "node-5": node, + }, + leaflet: { + setView: jest.fn(), + }, + }; + util.applyUrlFragmentState.call(util, mockSelf); + expect(mockSelf.config.onClickElement).toHaveBeenCalledWith("node", node); + }); +}); + +describe("Test removeUrlFragment with paramName argument", () => { + test("removeUrlFragment deletes only the named param when paramName is provided", () => { + const util = new NetJSONGraphUtil(); + const params = new URLSearchParams(); + params.set("id", "id"); + params.set("nodeId", "node-1"); + params.set("other", "value"); + util.parseUrlFragments = jest.fn(() => ({ + id: params, + })); + util.updateUrlFragments = jest.fn(); + util.removeUrlFragment.call(util, "id", "nodeId"); + expect(util.updateUrlFragments).toHaveBeenCalledWith({id: params}, {id: "id"}); + expect(params.has("nodeId")).toBe(false); + expect(params.get("other")).toBe("value"); + }); + + test("removeUrlFragment deletes entire fragment when paramName is not provided", () => { + const util = new NetJSONGraphUtil(); + util.parseUrlFragments = jest.fn(() => ({ + id: new URLSearchParams("nodeId=node-1"), + })); + util.updateUrlFragments = jest.fn(); + util.removeUrlFragment.call(util, "id"); + expect(util.updateUrlFragments).toHaveBeenCalledWith({}, {id: "id"}); + }); + + test("removeUrlFragment returns early when fragment does not exist", () => { + const util = new NetJSONGraphUtil(); + util.parseUrlFragments = jest.fn(() => ({})); + util.updateUrlFragments = jest.fn(); + util.removeUrlFragment.call(util, "nonexistent"); + expect(util.updateUrlFragments).not.toHaveBeenCalled(); + }); + + test("removeUrlFragment drops the whole fragment entry when only the action id remains", () => { + // If removing the named param leaves nothing but the bare `id` key, the + // fragment becomes a useless stub like "#id=geoMap" — drop it entirely. + const util = new NetJSONGraphUtil(); + const params = new URLSearchParams(); + params.set("id", "geoMap"); + params.set("nodeId", "node-1"); + util.parseUrlFragments = jest.fn(() => ({ + geoMap: params, + })); + util.updateUrlFragments = jest.fn(); + util.removeUrlFragment.call(util, "geoMap", "nodeId"); + // After deletion, only `id` would remain → entire entry should be gone. + expect(util.updateUrlFragments).toHaveBeenCalledWith({}, {id: "geoMap"}); + }); +}); + +describe("Test updateLabelVisibility utility method", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("updateLabelVisibility hides labels when show is false", () => { + const util = new NetJSONGraphUtil(); + const mockSelf = { + echarts: { + setOption: jest.fn(), + }, + config: { + showMapLabelsAtZoom: 3, + }, + leaflet: { + getZoom: jest.fn(() => 5), + }, + }; + util.updateLabelVisibility.call(util, mockSelf, false); + expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({ + series: [ + { + id: "geo-map", + label: { + show: false, + silent: true, + }, + emphasis: { + label: { + show: false, + }, + }, + }, + ], + }); + }); + + test("updateLabelVisibility shows labels when show is true and zoom >= threshold", () => { + const util = new NetJSONGraphUtil(); + const mockSelf = { + echarts: { + setOption: jest.fn(), + }, + config: { + showMapLabelsAtZoom: 3, + }, + leaflet: { + getZoom: jest.fn(() => 5), + }, + }; + util.updateLabelVisibility.call(util, mockSelf, true); + expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({ + series: [ + { + id: "geo-map", + label: { + show: true, + silent: true, + }, + emphasis: { + label: { + show: false, + }, + }, + }, + ], + }); + }); + + test("updateLabelVisibility hides labels when zoom < threshold even if show is true", () => { + const util = new NetJSONGraphUtil(); + const mockSelf = { + echarts: { + setOption: jest.fn(), + }, + config: { + showMapLabelsAtZoom: 10, + }, + leaflet: { + getZoom: jest.fn(() => 5), + }, + }; + util.updateLabelVisibility.call(util, mockSelf, true); + expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({ + series: [ + { + id: "geo-map", + label: { + show: false, + silent: true, + }, + emphasis: { + label: { + show: false, + }, + }, + }, + ], + }); + }); + + test("updateLabelVisibility always hides labels when showMapLabelsAtZoom is false", () => { + const util = new NetJSONGraphUtil(); + const mockSelf = { + echarts: { + setOption: jest.fn(), + }, + config: { + showMapLabelsAtZoom: false, + }, + leaflet: { + getZoom: jest.fn(() => 10), + }, + }; + util.updateLabelVisibility.call(util, mockSelf, true); + expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({ + series: [ + { + id: "geo-map", + label: { + show: false, + silent: true, + }, + emphasis: { + label: { + show: false, + }, + }, + }, + ], + }); + }); + + test("updateLabelVisibility returns early when echarts is not ready", () => { + const util = new NetJSONGraphUtil(); + const mockSelf = { + echarts: null, + config: { + showMapLabelsAtZoom: 3, + }, + leaflet: { + getZoom: jest.fn(() => 5), + }, + }; + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + util.updateLabelVisibility.call(util, mockSelf, true); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "updateLabelVisibility: ECharts instance not ready", + ); + }); +});