Skip to content

Commit c9f3a8a

Browse files
improvement: preserve query parameters in nav menu (#7207)
Fixes #7131 Preserve query parameters when passing in urls to the NavMenu's. This only preserves `file` for now to change user code as little as possible, but we can add more if needed (or preserve all) --------- Co-authored-by: Shahmir Varqha <sham9871@gmail.com>
1 parent c3c0388 commit c9f3a8a

File tree

4 files changed

+304
-4
lines changed

4 files changed

+304
-4
lines changed

frontend/src/css/md.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ a .markdown iconify-icon:first-child {
7878
margin-inline-end: 0.4em;
7979
}
8080

81+
/* make links and their contents inline-flex to avoid extra gaps */
82+
a > .markdown > .paragraph {
83+
display: inline-flex;
84+
}
85+
8186
iconify-icon {
8287
display: inline-flex;
8388
align-items: center;

frontend/src/plugins/layout/NavigationMenuPlugin.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {
1414
import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
1515
import { renderHTML } from "@/plugins/core/RenderHTML";
1616
import { cn } from "@/utils/cn";
17+
import { appendQueryParams } from "@/utils/urls";
1718
import type {
1819
IStatelessPlugin,
1920
IStatelessPluginProps,
2021
} from "../stateless-plugin";
2122
import "./navigation-menu.css";
23+
import { KnownQueryParams } from "@/core/constants";
2224

2325
interface MenuItem {
2426
label: string;
@@ -99,6 +101,15 @@ const NavMenuComponent = ({
99101
return "_self";
100102
};
101103

104+
const preserveQueryParams = (href: string) => {
105+
const currentUrl = new URL(globalThis.location.href);
106+
return appendQueryParams({
107+
href,
108+
queryParams: currentUrl.search,
109+
keys: [KnownQueryParams.filePath],
110+
});
111+
};
112+
102113
const renderMenuItem = (item: MenuItem | MenuItemGroup) => {
103114
if ("items" in item) {
104115
return orientation === "horizontal" ? (
@@ -113,7 +124,7 @@ const NavMenuComponent = ({
113124
<ListItem
114125
key={subItem.label}
115126
label={subItem.label}
116-
href={subItem.href}
127+
href={preserveQueryParams(subItem.href)}
117128
target={target(subItem.href)}
118129
>
119130
{subItem.description &&
@@ -142,7 +153,7 @@ const NavMenuComponent = ({
142153
{maybeWithTooltip(
143154
<NavigationMenuLink
144155
key={subItem.label}
145-
href={subItem.href}
156+
href={preserveQueryParams(subItem.href)}
146157
target={target(subItem.href)}
147158
className={navigationMenuTriggerStyle({
148159
orientation: orientation,
@@ -162,7 +173,7 @@ const NavMenuComponent = ({
162173
return (
163174
<NavigationMenuItem key={item.label}>
164175
<NavigationMenuLink
165-
href={item.href}
176+
href={preserveQueryParams(item.href)}
166177
target={target(item.href)}
167178
className={navigationMenuTriggerStyle({
168179
orientation: orientation,

frontend/src/utils/__tests__/urls.test.ts

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
EDGE_CASE_FILENAMES,
55
URL_SPECIAL_CHAR_FILENAMES,
66
} from "../../__tests__/mocks";
7-
import { isUrl, updateQueryParams } from "../urls";
7+
import { appendQueryParams, isUrl, updateQueryParams } from "../urls";
88

99
describe("isUrl", () => {
1010
it("should return true for a valid URL", () => {
@@ -63,3 +63,167 @@ describe("URL parameter handling with edge case filenames", () => {
6363
});
6464
});
6565
});
66+
67+
describe("appendQueryParams", () => {
68+
it("should append params to a simple path", () => {
69+
const result = appendQueryParams({
70+
href: "/about",
71+
queryParams: new URLSearchParams("file=test.py"),
72+
});
73+
expect(result).toBe("/about?file=test.py");
74+
});
75+
76+
it("should append params to a hash-based path", () => {
77+
const result = appendQueryParams({
78+
href: "#/about",
79+
queryParams: new URLSearchParams("file=test.py"),
80+
});
81+
expect(result).toBe("/?file=test.py#/about");
82+
});
83+
84+
it("should accept query string instead of URLSearchParams", () => {
85+
const result = appendQueryParams({
86+
href: "/about",
87+
queryParams: "file=test.py&mode=edit",
88+
});
89+
expect(result).toBe("/about?file=test.py&mode=edit");
90+
});
91+
92+
it("should filter params by keys when provided", () => {
93+
const result = appendQueryParams({
94+
href: "/about",
95+
queryParams: "file=test.py&mode=edit&extra=data",
96+
keys: ["file", "mode"],
97+
});
98+
expect(result).toBe("/about?file=test.py&mode=edit");
99+
});
100+
101+
it("should filter params by keys in the middle of the query string", () => {
102+
const result = appendQueryParams({
103+
href: "/about",
104+
queryParams: "file=test.py&mode=edit&extra=data",
105+
keys: ["file", "extra"],
106+
});
107+
expect(result).toBe("/about?file=test.py&extra=data");
108+
});
109+
110+
it("should preserve existing query params", () => {
111+
const result = appendQueryParams({
112+
href: "/about?existing=1",
113+
queryParams: "file=test.py",
114+
});
115+
expect(result).toBe("/about?existing=1&file=test.py");
116+
});
117+
118+
it("should overwrite existing params with same key", () => {
119+
const result = appendQueryParams({
120+
href: "/about?file=old.py",
121+
queryParams: "file=new.py",
122+
});
123+
expect(result).toBe("/about?file=new.py");
124+
});
125+
126+
it("should preserve hash fragment and put params before it", () => {
127+
const result = appendQueryParams({
128+
href: "/about#section",
129+
queryParams: "file=test.py",
130+
});
131+
expect(result).toBe("/about?file=test.py#section");
132+
});
133+
134+
it("should handle hash-based path with existing params and hash", () => {
135+
const result = appendQueryParams({
136+
href: "#/about?existing=1",
137+
queryParams: "file=test.py",
138+
});
139+
expect(result).toBe("#/about?existing=1&file=test.py");
140+
});
141+
142+
it("should handle hash-based path", () => {
143+
const result = appendQueryParams({
144+
href: "#/about",
145+
queryParams: "file=test.py",
146+
});
147+
expect(result).toBe("/?file=test.py#/about");
148+
});
149+
150+
it("should not modify external links", () => {
151+
const httpUrl = "http://example.com/page";
152+
const httpsUrl = "https://example.com/page";
153+
154+
expect(
155+
appendQueryParams({
156+
href: httpUrl,
157+
queryParams: "file=test.py",
158+
}),
159+
).toBe(httpUrl);
160+
161+
expect(
162+
appendQueryParams({
163+
href: httpsUrl,
164+
queryParams: "file=test.py",
165+
}),
166+
).toBe(httpsUrl);
167+
});
168+
169+
it("should return original href when no params to append", () => {
170+
const result = appendQueryParams({
171+
href: "/about",
172+
queryParams: new URLSearchParams(),
173+
});
174+
expect(result).toBe("/about");
175+
});
176+
177+
it("should handle complex scenarios with all features", () => {
178+
const result = appendQueryParams({
179+
href: "#/dashboard?view=grid#top",
180+
queryParams: "file=notebook.py&view=list&mode=edit",
181+
keys: ["file", "mode"],
182+
});
183+
// view=grid should remain, file and mode should be added (view is not in keys)
184+
expect(result).toBe("#/dashboard?view=grid&file=notebook.py&mode=edit#top");
185+
});
186+
187+
it("should handle empty path", () => {
188+
const result = appendQueryParams({
189+
href: "",
190+
queryParams: "file=test.py",
191+
});
192+
expect(result).toBe("?file=test.py");
193+
});
194+
195+
it("should handle just a hash", () => {
196+
const result = appendQueryParams({
197+
href: "#",
198+
queryParams: "file=test.py",
199+
});
200+
expect(result).toBe("/?file=test.py#");
201+
});
202+
203+
it("should handle unicode filenames in params", () => {
204+
const result = appendQueryParams({
205+
href: "/about",
206+
queryParams: new URLSearchParams([
207+
["file", "文件.py"],
208+
["name", "テスト"],
209+
]),
210+
});
211+
expect(result).toContain("/about?");
212+
// Verify the params are properly encoded
213+
const url = new URL(result, "http://example.com");
214+
expect(url.searchParams.get("file")).toBe("文件.py");
215+
expect(url.searchParams.get("name")).toBe("テスト");
216+
});
217+
218+
it("should handle special characters in param values", () => {
219+
const result = appendQueryParams({
220+
href: "/about",
221+
queryParams: new URLSearchParams([
222+
["path", "folder/file with spaces.py"],
223+
]),
224+
});
225+
expect(result).toContain("/about?");
226+
const url = new URL(result, "http://example.com");
227+
expect(url.searchParams.get("path")).toBe("folder/file with spaces.py");
228+
});
229+
});

frontend/src/utils/urls.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,123 @@ const urlRegex = /^(https?:\/\/\S+)$/;
3131
export function isUrl(value: unknown): boolean {
3232
return typeof value === "string" && urlRegex.test(value);
3333
}
34+
35+
/**
36+
* Appends query parameters to an href, handling various edge cases.
37+
*
38+
* @param href - The href to append params to (e.g., "/path", "#/hash", "/path?existing=1", "/path#hash")
39+
* @param queryParams - URLSearchParams or query string to append
40+
* @param keys - Optional array of keys to filter which params to append
41+
* @returns The href with appended query parameters
42+
*
43+
* @example
44+
* appendQueryParams({ href: "/about", queryParams: new URLSearchParams("file=test.py") })
45+
* // Returns: "/about?file=test.py"
46+
*
47+
* @example
48+
* appendQueryParams({ href: "#/about", queryParams: "file=test.py&mode=edit", keys: ["file"] })
49+
* // Returns: "/?file=test.py#/about"
50+
*
51+
* @example
52+
* appendQueryParams({ href: "#/about?existing=1", queryParams: "file=test.py" })
53+
* // Returns: "#/about?existing=1&file=test.py"
54+
*
55+
* @example
56+
* appendQueryParams({ href: "/about?existing=1", queryParams: "file=test.py" })
57+
* // Returns: "/about?existing=1&file=test.py"
58+
*
59+
* @example
60+
* appendQueryParams({ href: "/about#section", queryParams: "file=test.py" })
61+
* // Returns: "/about?file=test.py#section"
62+
*/
63+
export function appendQueryParams({
64+
href,
65+
queryParams,
66+
keys,
67+
}: {
68+
href: string;
69+
queryParams: URLSearchParams | string;
70+
keys?: string[];
71+
}): string {
72+
// Convert queryParams to URLSearchParams if it's a string
73+
const params =
74+
typeof queryParams === "string"
75+
? new URLSearchParams(queryParams)
76+
: queryParams;
77+
78+
// If no params to append, return as is
79+
if (params.size === 0) {
80+
return href;
81+
}
82+
83+
// Don't modify external links (full URLs)
84+
if (href.startsWith("http://") || href.startsWith("https://")) {
85+
return href;
86+
}
87+
88+
// Special handling for hash-based routing
89+
const isHashBased = href.startsWith("#");
90+
const hasQueryInHash = isHashBased && href.includes("?");
91+
92+
if (isHashBased && !hasQueryInHash) {
93+
// For hash-based hrefs without query params (e.g., #/about),
94+
// put query params on the main path before the hash: /?params#/route
95+
// This is common in SPAs where query params on the main URL need to be preserved
96+
const paramsToAdd = keys
97+
? [...params.entries()].filter(([key]) => keys.includes(key))
98+
: [...params.entries()];
99+
100+
const queryParams = new URLSearchParams();
101+
for (const [key, value] of paramsToAdd) {
102+
queryParams.set(key, value);
103+
}
104+
105+
const queryString = queryParams.toString();
106+
if (!queryString) {
107+
return href;
108+
}
109+
110+
return `/?${queryString}${href}`;
111+
}
112+
113+
// Parse the href to extract parts for all other cases
114+
let basePath = href;
115+
let hash = "";
116+
let existingParams = new URLSearchParams();
117+
118+
// For hash-based routing with query params, look for a second # to find actual hash fragment
119+
const hashSearchStart = isHashBased ? 1 : 0;
120+
const hashIndex = href.indexOf("#", hashSearchStart);
121+
122+
if (hashIndex !== -1) {
123+
hash = href.slice(hashIndex);
124+
basePath = href.slice(0, hashIndex);
125+
}
126+
127+
// Extract existing query params
128+
const queryIndex = basePath.indexOf("?");
129+
if (queryIndex !== -1) {
130+
existingParams = new URLSearchParams(basePath.slice(queryIndex + 1));
131+
basePath = basePath.slice(0, queryIndex);
132+
}
133+
134+
// Merge params (new params overwrite existing ones with same key)
135+
const mergedParams = new URLSearchParams(existingParams);
136+
137+
// Filter params by keys if provided
138+
const paramsToAdd = keys
139+
? [...params.entries()].filter(([key]) => keys.includes(key))
140+
: [...params.entries()];
141+
142+
for (const [key, value] of paramsToAdd) {
143+
mergedParams.set(key, value);
144+
}
145+
146+
// Build the final URL
147+
const queryString = mergedParams.toString();
148+
if (!queryString) {
149+
return href;
150+
}
151+
152+
return `${basePath}?${queryString}${hash}`;
153+
}

0 commit comments

Comments
 (0)