mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 20:34:05 +02:00
Allow renaming URL path parameters from the Params tab (#528)
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { extractPathPlaceholders } from "./pathPlaceholders";
|
||||
import {
|
||||
derivePathPlaceholderPairs,
|
||||
extractPathPlaceholders,
|
||||
renamePathPlaceholder,
|
||||
} from "./pathPlaceholders";
|
||||
|
||||
describe("extractPathPlaceholders", () => {
|
||||
test("extracts a single placeholder", () => {
|
||||
@@ -26,3 +30,185 @@ describe("extractPathPlaceholders", () => {
|
||||
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePathPlaceholderPairs", () => {
|
||||
const neverRename = () => false;
|
||||
|
||||
test("adds a row for a placeholder with no parameter", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id", value: "", enabled: true }]);
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("gives the existing parameter for a placeholder a commitName, without mutating it", () => {
|
||||
const parameter = { name: ":id", value: "123", enabled: true, id: "p1" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[parameter],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toMatchObject({ name: ":id", value: "123", id: "p1" });
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
expect(parameter).toEqual({ name: ":id", value: "123", enabled: true, id: "p1" });
|
||||
});
|
||||
|
||||
test("leaves query parameters alone", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[{ name: "q", value: "hi", enabled: true, id: "p1" }],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toEqual({ name: "q", value: "hi", enabled: true, id: "p1" });
|
||||
expect(urlParameterPairs[1]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("commitName renames this row's placeholder", () => {
|
||||
const renames: [string, string][] = [];
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/a/:x/b/:y",
|
||||
[],
|
||||
(oldName, newName) => {
|
||||
renames.push([oldName, newName]);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
urlParameterPairs[1]?.commitName?.(":z");
|
||||
expect(renames).toEqual([[":y", ":z"]]);
|
||||
});
|
||||
|
||||
test("drops empty parameters", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users",
|
||||
[
|
||||
{ name: "", value: "", enabled: true, id: "p1" },
|
||||
{ name: "q", value: "", enabled: true, id: "p2" },
|
||||
],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: "q", id: "p2" }]);
|
||||
});
|
||||
|
||||
test("collapses a placeholder that appears twice into one row", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/a/:id/b/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id" }]);
|
||||
});
|
||||
|
||||
test("gives a derived row the same id every time, so re-deriving is stable", () => {
|
||||
const first = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
const second = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(first.urlParameterPairs[0]?.id).toEqual(second.urlParameterPairs[0]?.id);
|
||||
});
|
||||
|
||||
test("derived row ids avoid colliding with a persisted derived id", () => {
|
||||
// A derived id sticks to the parameter once the user gives the row a value. If its placeholder
|
||||
// is then renamed away in the URL bar, the parameter survives as a stray still holding the id,
|
||||
// and the replacement placeholder's row must not collide with it.
|
||||
const stray = { name: ":old", value: "42", enabled: true, id: "path-placeholder:0" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/pets/:new", [stray], neverRename);
|
||||
const ids = urlParameterPairs.map((p) => p.id);
|
||||
expect(new Set(ids).size).toEqual(ids.length);
|
||||
});
|
||||
|
||||
test("keeps a derived row's id stable across a rename", () => {
|
||||
const before = derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename);
|
||||
const after = derivePathPlaceholderPairs("/a/:x2/b/:y", [], neverRename);
|
||||
expect(after.urlParameterPairs.map((p) => p.id)).toEqual(
|
||||
before.urlParameterPairs.map((p) => p.id),
|
||||
);
|
||||
});
|
||||
|
||||
test("keys off the placeholder names", () => {
|
||||
expect(derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename).urlParametersKey).toEqual(
|
||||
":x,:y",
|
||||
);
|
||||
expect(derivePathPlaceholderPairs("/a/b", [], neverRename).urlParametersKey).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renamePathPlaceholder", () => {
|
||||
const model = (url: string, urlParameters: { name: string; value: string }[] = []) => ({
|
||||
url,
|
||||
urlParameters,
|
||||
});
|
||||
|
||||
test("renames the placeholder in the URL", () => {
|
||||
expect(
|
||||
renamePathPlaceholder(model("https://x.com/pets/:petId/info"), ":petId", ":animalId"),
|
||||
).toEqual({ url: "https://x.com/pets/:animalId/info", urlParameters: [] });
|
||||
});
|
||||
|
||||
test("carries the parameter value over to the new name", () => {
|
||||
const patch = renamePathPlaceholder(
|
||||
model("/pets/:petId", [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":petId", value: "42" },
|
||||
]),
|
||||
":petId",
|
||||
":animalId",
|
||||
);
|
||||
expect(patch).toEqual({
|
||||
url: "/pets/:animalId",
|
||||
urlParameters: [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":animalId", value: "42" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("renames every occurrence of a repeated placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:id"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:key",
|
||||
);
|
||||
});
|
||||
|
||||
test("adds a missing leading colon", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", "animalId")?.url).toEqual(
|
||||
"/pets/:animalId",
|
||||
);
|
||||
});
|
||||
|
||||
test("renames a placeholder followed by a literal colon", () => {
|
||||
expect(renamePathPlaceholder(model("/tasks/:id:cancel"), ":id", ":taskId")?.url).toEqual(
|
||||
"/tasks/:taskId:cancel",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not rename a placeholder the new name is a prefix of", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:idx"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:idx",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not touch a same-named segment that isn't a placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/id/:id?x=:id"), ":id", ":key")?.url).toEqual(
|
||||
"/id/:key?x=:id",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats regex characters in the old name literally", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:i.d/b/:iXd"), ":i.d", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:iXd",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([[""], [":"], [":a/b"], [":a?b"], [":a#b"], [":a:b"], [":a b"], [":a\tb"]])(
|
||||
"rejects the unusable name %j",
|
||||
(name) => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", name)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a name already used by another placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId/:ownerId"), ":petId", ":ownerId")).toBeNull();
|
||||
});
|
||||
|
||||
test("allows renaming a placeholder to itself", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", ":petId")?.url).toEqual(
|
||||
"/pets/:petId",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects renaming a placeholder that isn't in the URL", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":other", ":animalId")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { HttpUrlParameter } from "@yaakapp-internal/models";
|
||||
import type { EditablePair } from "../components/core/PairEditor";
|
||||
|
||||
/**
|
||||
* Extract `:name`-style path placeholders from a URL string.
|
||||
*
|
||||
@@ -12,3 +15,88 @@
|
||||
export function extractPathPlaceholders(url: string): string[] {
|
||||
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rows for the Params tab: the request's URL parameters, plus a row for each path
|
||||
* placeholder in the URL that doesn't have one yet. A placeholder that appears more than once
|
||||
* in the URL still gets a single row.
|
||||
*
|
||||
* Only placeholder rows get a `commitName`, which makes the editor hold name edits until blur and
|
||||
* hand them to `renamePlaceholder` instead of writing on every keystroke — renaming has to rewrite
|
||||
* the URL too. `renamePlaceholder` returns false to reject the new name, which reverts the field.
|
||||
*
|
||||
* `urlParametersKey` changes whenever the URL's placeholders do, and is used to reset the pair
|
||||
* editor so derived rows appear and disappear along with the URL.
|
||||
*/
|
||||
export function derivePathPlaceholderPairs(
|
||||
url: string,
|
||||
urlParameters: HttpUrlParameter[],
|
||||
renamePlaceholder: (oldName: string, newName: string) => boolean,
|
||||
): { urlParameterPairs: EditablePair[]; urlParametersKey: string } {
|
||||
const placeholderNames = extractPathPlaceholders(url);
|
||||
const commitNameFor = (oldName: string) => (newName: string) =>
|
||||
renamePlaceholder(oldName, newName);
|
||||
|
||||
// NOTE: Copy each parameter because `commitName` is UI-only. Adding it in place would mutate the
|
||||
// persisted model.
|
||||
const urlParameterPairs: EditablePair[] = urlParameters
|
||||
.filter((p) => p.name || p.value)
|
||||
.map((p) =>
|
||||
placeholderNames.includes(p.name) ? { ...p, commitName: commitNameFor(p.name) } : { ...p },
|
||||
);
|
||||
|
||||
// NOTE: Ids are derived from the placeholder's position instead of generated, so neither
|
||||
// re-deriving nor renaming hands a row a new identity. The pair editor keys rows by id, so a
|
||||
// changed id remounts the row and drops the user's focus.
|
||||
//
|
||||
// A derived id sticks to the parameter once the user gives the row a value, so a parameter that
|
||||
// outlives its placeholder (renamed away in the URL bar) still holds one. Skip past taken ids
|
||||
// so a new placeholder at that position can't collide with it.
|
||||
const takenIds = new Set(urlParameterPairs.map((p) => p.id));
|
||||
const uniquePlaceholderNames = [...new Set(placeholderNames)];
|
||||
for (const [index, name] of uniquePlaceholderNames.entries()) {
|
||||
if (urlParameterPairs.some((p) => p.name === name)) continue;
|
||||
|
||||
let id = `path-placeholder:${index}`;
|
||||
for (let bump = index + 1; takenIds.has(id); bump++) id = `path-placeholder:${bump}`;
|
||||
takenIds.add(id);
|
||||
|
||||
urlParameterPairs.push({ name, value: "", enabled: true, commitName: commitNameFor(name), id });
|
||||
}
|
||||
|
||||
return { urlParameterPairs, urlParametersKey: placeholderNames.join(",") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the patch for renaming a path placeholder: every occurrence replaced in the URL, and
|
||||
* the matching URL parameter renamed so the user's value follows along. Both have to be applied
|
||||
* together, or the value detaches from the placeholder.
|
||||
*
|
||||
* Returns `null` when the rename can't be applied, meaning the caller should leave the model
|
||||
* alone. That's the case when the new name wouldn't parse as a placeholder anymore (empty, or
|
||||
* containing `/`, `?`, `#`, `:`, or whitespace) or when it's already used by another placeholder
|
||||
* in the URL. A missing leading `:` is added rather than rejected, since focusing the name field
|
||||
* selects all of its text and typing over it is the natural way to rename.
|
||||
*/
|
||||
export function renamePathPlaceholder(
|
||||
model: { url: string; urlParameters: HttpUrlParameter[] },
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): { url: string; urlParameters: HttpUrlParameter[] } | null {
|
||||
const name = newName.startsWith(":") ? newName : `:${newName}`;
|
||||
if (!/^:[^/?#:\s]+$/.test(name)) return null;
|
||||
|
||||
const placeholderNames = extractPathPlaceholders(model.url);
|
||||
if (!placeholderNames.includes(oldName)) return null;
|
||||
if (name !== oldName && placeholderNames.includes(name)) return null;
|
||||
|
||||
const pattern = new RegExp(`(/)${escapeRegExp(oldName)}(?=[/?#:]|$)`, "g");
|
||||
return {
|
||||
url: model.url.replace(pattern, (_match, slash: string) => `${slash}${name}`),
|
||||
urlParameters: model.urlParameters.map((p) => (p.name === oldName ? { ...p, name } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user