Skip to content

Commit ced65ac

Browse files
dee077nemesifier
andauthored
[chores:fix] Preserve indoor overlay URL fragment on popup close #546
Added configurable fragment preservation support for bookmarkable actions so indoor map overlays retain their URL fragment when only the popup nodeId is removed. Fixes #546 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent b2ea68a commit ced65ac

7 files changed

Lines changed: 105 additions & 15 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc
566566
id: string,
567567
zoomOnRestore: boolean,
568568
zoomLevel: number,
569+
preserveFragment: boolean,
569570
}
570571
```
571572

@@ -587,7 +588,8 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc
587588

588589
For links, the URL fragment uses the format `source~target` as the `nodeId`. Opening such a URL restores the initial map or graph view and triggers the corresponding link click event.
589590

590-
If you need to manually remove the URL fragment, you can call the built-in utility method: `netjsongraphInstance.utils.removeUrlFragment(bookmarkableActions.id);` where you pass the value of your `bookmarkableActions.id` configuration.
591+
If you need to manually remove the URL fragment, you can call the built-in utility method: `netjsongraphInstance.utils.removeUrlFragment(bookmarkableActions.id);` where you pass the value of your `bookmarkableActions.id` configuration. You can also remove only a specific parameter from the fragment, for example:
592+
`netjsongraphInstance.utils.removeUrlFragment(bookmarkableActions.id, "nodeId");`. This removes only the `nodeId` parameter from the URL fragment for that specific map while preserving the remaining fragment state. To keep the fragment itself after removing `nodeId`, make sure to set `bookmarkableActions.preserveFragment = true` otherwise, if no additional parameters remain after removing `nodeId`, the entire fragment will be cleaned up automatically.
591593

592594
- `onInit`
593595

public/example_templates/netjsonmap-indoormap-overlay.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@
314314
enabled: true,
315315
id: "indoorMap",
316316
zoomOnRestore: false,
317+
preserveFragment: true,
317318
},
318319
prepareData: (data) => {
319320
data.nodes.forEach((n) => {

src/js/netjsongraph.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ const NetJSONGraphDefaultConfig = {
301301
id: null,
302302
zoomOnRestore: true,
303303
zoomLevel: null,
304+
preserveFragment: false,
304305
},
305306

306307
/**

src/js/netjsongraph.gui.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,8 @@ class NetJSONGraphGUI {
325325
console.error("Node location not available. Cannot load popup.");
326326
return;
327327
}
328-
const bookmarkableActionId =
329-
self.config.bookmarkableActions && self.config.bookmarkableActions.id;
328+
const {bookmarkableActions: {id: bookmarkableActionId, preserveFragment} = {}} =
329+
self.config;
330330
const popupRequest = {};
331331
self.leaflet.currentPopupRequest = popupRequest;
332332
// Track whether tooltip/labels were already hidden by an open popup, so
@@ -354,7 +354,7 @@ class NetJSONGraphGUI {
354354
if (self.leaflet.currentPopupRequest !== popupRequest) {
355355
return;
356356
}
357-
self.utils.removeUrlFragment(bookmarkableActionId, "nodeId");
357+
self.utils.removeUrlFragment(bookmarkableActionId, "nodeId", preserveFragment);
358358
self.leaflet.currentPopupRequest = null;
359359
// If we tore down a previous popup before content generation failed,
360360
// its remove handler was bypassed — restore tooltip/labels manually so
@@ -397,7 +397,11 @@ class NetJSONGraphGUI {
397397
const fragments = self.utils.parseUrlFragments();
398398
const currentFragment = fragments[bookmarkableActionId];
399399
if (currentFragment && currentFragment.get("nodeId") === popupNodeId) {
400-
self.utils.removeUrlFragment(bookmarkableActionId, "nodeId");
400+
self.utils.removeUrlFragment(
401+
bookmarkableActionId,
402+
"nodeId",
403+
preserveFragment,
404+
);
401405
}
402406
}
403407
self.leaflet.currentPopup = null;

src/js/netjsongraph.util.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,6 +1270,16 @@ class NetJSONGraphUtil {
12701270
.map((urlParams) => urlParams.toString())
12711271
.join(";");
12721272

1273+
// Remove dangling "#" when no fragments remain.
1274+
if (!newHash) {
1275+
window.history.pushState(
1276+
state,
1277+
"",
1278+
window.location.pathname + window.location.search,
1279+
);
1280+
return;
1281+
}
1282+
12731283
// We store the selected node's data to the browser's history state.
12741284
// This allows the node's information to be retrieved instantly on a back/forward
12751285
// button click without needing to re-parse the entire nodes list.
@@ -1335,19 +1345,22 @@ class NetJSONGraphUtil {
13351345
* @param {string} id The bookmarkable action id (e.g. "geoMap").
13361346
* @param {string} [paramName] If provided, only this query-param is removed
13371347
* from the fragment. If omitted, the whole fragment for the id is dropped.
1348+
* @param {boolean} [preserveFragment] If true, keep a bare id-only fragment
1349+
* after removing paramName.
13381350
*/
1339-
removeUrlFragment(id, paramName = null) {
1351+
removeUrlFragment(id, paramName = null, preserveFragment = false) {
13401352
const fragments = this.parseUrlFragments();
13411353
if (!fragments[id]) {
13421354
return;
13431355
}
13441356
if (paramName) {
13451357
fragments[id].delete(paramName);
1346-
// Drop the whole entry if only the bare action id is left — a fragment
1347-
// like "#id=geoMap" with no other params is a useless stub that
1348-
// parseUrlFragments would still pick up on subsequent visits.
1358+
// Remove the entire fragment if only the bare action id remains,
1359+
// unless preserveFragment is enabled. Some consumers (e.g. indoor
1360+
// overlays) use the fragment itself as meaningful UI state even
1361+
// without additional params like nodeId.
13491362
const remainingKeys = Array.from(fragments[id].keys()).filter((k) => k !== "id");
1350-
if (remainingKeys.length === 0) {
1363+
if (!preserveFragment && remainingKeys.length === 0) {
13511364
delete fragments[id];
13521365
}
13531366
} else {

test/netjsongraph.dom.test.js

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,11 @@ describe("Test GUI loadNodePopup with async and tooltip handling", () => {
737737
testGraph.utils.removeUrlFragment = jest.fn();
738738
const node = {id: "node-1", location: {lat: 10, lng: 20}};
739739
await testGraph.gui.loadNodePopup(node);
740-
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
740+
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith(
741+
"id",
742+
"nodeId",
743+
false,
744+
);
741745
expect(testGraph.echarts.setOption).not.toHaveBeenCalled();
742746
expect(testGraph.utils.updateLabelVisibility).not.toHaveBeenCalled();
743747
});
@@ -778,7 +782,11 @@ describe("Test GUI loadNodePopup with async and tooltip handling", () => {
778782
await testGraph.gui.loadNodePopup(node);
779783
expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, true);
780784
expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith(testGraph, true);
781-
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
785+
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith(
786+
"id",
787+
"nodeId",
788+
false,
789+
);
782790
});
783791

784792
test("loadNodePopup catches synchronous custom content errors", async () => {
@@ -807,7 +815,11 @@ describe("Test GUI loadNodePopup with async and tooltip handling", () => {
807815
testGraph.utils.removeUrlFragment = jest.fn();
808816
const node = {id: "node-1", location: {lat: 10, lng: 20}};
809817
await testGraph.gui.loadNodePopup(node);
810-
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
818+
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith(
819+
"id",
820+
"nodeId",
821+
false,
822+
);
811823
expect(testGraph.echarts.setOption).not.toHaveBeenCalled();
812824
});
813825

@@ -832,7 +844,43 @@ describe("Test GUI loadNodePopup with async and tooltip handling", () => {
832844
mockPopup.handlers.remove();
833845
expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, true);
834846
expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith(testGraph, true);
835-
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
847+
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith(
848+
"id",
849+
"nodeId",
850+
false,
851+
);
852+
});
853+
854+
test("loadNodePopup forwards preserveFragment on popup remove", async () => {
855+
testGraph.setConfig({
856+
bookmarkableActions: {
857+
enabled: true,
858+
id: "id",
859+
preserveFragment: true,
860+
},
861+
});
862+
testGraph.echarts = {
863+
setOption: jest.fn(),
864+
};
865+
testGraph.leaflet = {
866+
currentPopup: null,
867+
once: jest.fn(),
868+
off: jest.fn(),
869+
};
870+
testGraph.utils.updateLabelVisibility = jest.fn();
871+
testGraph.utils.setTooltipVisibility = jest.fn();
872+
testGraph.utils.parseUrlFragments = jest.fn(() => ({
873+
id: new URLSearchParams("id=id&nodeId=node-1"),
874+
}));
875+
testGraph.utils.removeUrlFragment = jest.fn();
876+
const node = {id: "node-1", location: {lat: 10, lng: 20}};
877+
await testGraph.gui.loadNodePopup(node);
878+
mockPopup.handlers.remove();
879+
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith(
880+
"id",
881+
"nodeId",
882+
true,
883+
);
836884
});
837885

838886
test("loadNodePopup ignores stale async content without clearing newer URL fragment", async () => {
@@ -1032,7 +1080,11 @@ describe("Test GUI loadNodePopup with async and tooltip handling", () => {
10321080
const node = {id: "node-1", location: {lat: 10, lng: 20}};
10331081
await testGraph.gui.loadNodePopup(node);
10341082
expect(mockPopup.remove).toHaveBeenCalled();
1035-
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
1083+
expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith(
1084+
"id",
1085+
"nodeId",
1086+
false,
1087+
);
10361088
expect(consoleErrorSpy).toHaveBeenCalledWith(
10371089
"Failed to run popup onOpen callback:",
10381090
onOpenError,

test/netjsongraph.util.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,23 @@ describe("Test removeUrlFragment with paramName argument", () => {
732732
// After deletion, only `id` would remain → entire entry should be gone.
733733
expect(util.updateUrlFragments).toHaveBeenCalledWith({}, {id: "geoMap"});
734734
});
735+
736+
test("removeUrlFragment keeps an id-only fragment when preserveFragment is true", () => {
737+
const util = new NetJSONGraphUtil();
738+
const params = new URLSearchParams();
739+
params.set("id", "geoMap");
740+
params.set("nodeId", "node-1");
741+
util.parseUrlFragments = jest.fn(() => ({
742+
geoMap: params,
743+
}));
744+
util.updateUrlFragments = jest.fn();
745+
util.removeUrlFragment.call(util, "geoMap", "nodeId", true);
746+
expect(util.updateUrlFragments).toHaveBeenCalledWith(
747+
{geoMap: params},
748+
{id: "geoMap"},
749+
);
750+
expect(params.toString()).toBe("id=geoMap");
751+
});
735752
});
736753

737754
describe("Test updateLabelVisibility utility method", () => {

0 commit comments

Comments
 (0)