diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index 34d2926d..7841b4bc 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -1,5 +1,5 @@ import type { HttpRequest } from "@yaakapp-internal/models"; -import { patchModel } from "@yaakapp-internal/models"; +import { getModel, patchModel } from "@yaakapp-internal/models"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import classNames from "classnames"; import { atom, useAtomValue } from "jotai"; @@ -19,7 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; -import { extractPathPlaceholders } from "../lib/pathPlaceholders"; +import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { convertRequestBody } from "../lib/requestBodyConversion"; import { BODY_TYPE_BINARY, @@ -42,7 +42,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { Editor } from "./core/Editor/LazyEditor"; import { InlineCode } from "@yaakapp-internal/ui"; -import type { Pair } from "./core/PairEditor"; import { PlainInput } from "./core/PlainInput"; import type { TabItem, TabsRef } from "./core/Tabs/Tabs"; import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs"; @@ -133,20 +132,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: [activeRequest], ); - const { urlParameterPairs, urlParametersKey } = useMemo(() => { - const placeholderNames = extractPathPlaceholders(activeRequest.url); - const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); - const items: Pair[] = [...nonEmptyParameters]; - for (const name of placeholderNames) { - const item = items.find((p) => p.name === name); - if (item) { - item.readOnlyName = true; - } else { - items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() }); - } - } - return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") }; - }, [activeRequest.url, activeRequest.urlParameters]); + // Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the + // value detaches from the placeholder. + // NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this + // holds onto it until the URL's placeholders change, so a captured request would go stale and + // patch its parameter list back over newer edits. + const handleRenamePathPlaceholder = useCallback( + (oldName: string, newName: string) => { + const request = getModel("http_request", activeRequestId); + if (request == null) return false; + + const patch = renamePathPlaceholder(request, oldName, newName); + if (patch == null) return false; // Unusable name, so the editor reverts the field + void patchModel(request, patch); + return true; + }, + [activeRequestId], + ); + + const { urlParameterPairs, urlParametersKey } = useMemo( + () => + derivePathPlaceholderPairs( + activeRequest.url, + activeRequest.urlParameters, + handleRenamePathPlaceholder, + ), + [activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder], + ); let numParams = 0; if ( diff --git a/apps/yaak-client/components/UrlParameterEditor.tsx b/apps/yaak-client/components/UrlParameterEditor.tsx index c08607ea..aab2afad 100644 --- a/apps/yaak-client/components/UrlParameterEditor.tsx +++ b/apps/yaak-client/components/UrlParameterEditor.tsx @@ -1,15 +1,14 @@ -import type { HttpRequest } from "@yaakapp-internal/models"; import { VStack } from "@yaakapp-internal/ui"; import { useCallback, useRef } from "react"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; -import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor"; +import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor"; import { PairOrBulkEditor } from "./core/PairOrBulkEditor"; type Props = { forceUpdateKey: string; - pairs: HttpRequest["headers"]; + pairs: EditablePair[]; stateKey: PairEditorProps["stateKey"]; - onChange: (headers: HttpRequest["urlParameters"]) => void; + onChange: PairEditorProps["onChange"]; }; export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) { diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx index 2929aa93..ad20e71a 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -1,5 +1,5 @@ import type { WebsocketRequest } from "@yaakapp-internal/models"; -import { patchModel } from "@yaakapp-internal/models"; +import { getModel, patchModel } from "@yaakapp-internal/models"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws"; import classNames from "classnames"; @@ -20,8 +20,7 @@ import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEdit import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; -import { generateId } from "../lib/generateId"; -import { extractPathPlaceholders } from "../lib/pathPlaceholders"; +import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { resolvedModelName } from "../lib/resolvedModelName"; import { CountBadge } from "./core/CountBadge"; @@ -29,7 +28,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { Editor } from "./core/Editor/LazyEditor"; import { IconButton } from "./core/IconButton"; -import type { Pair } from "./core/PairEditor"; import { PlainInput } from "./core/PlainInput"; import type { TabItem, TabsRef } from "./core/Tabs/Tabs"; import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs"; @@ -84,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque [], ); - const { urlParameterPairs, urlParametersKey } = useMemo(() => { - const placeholderNames = extractPathPlaceholders(activeRequest.url); - const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); - const items: Pair[] = [...nonEmptyParameters]; - for (const name of placeholderNames) { - const item = items.find((p) => p.name === name); - if (item) { - item.readOnlyName = true; - } else { - items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() }); - } - } - return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") }; - }, [activeRequest.url, activeRequest.urlParameters]); + // Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the + // value detaches from the placeholder. + // NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this + // holds onto it until the URL's placeholders change, so a captured request would go stale and + // patch its parameter list back over newer edits. + const handleRenamePathPlaceholder = useCallback( + (oldName: string, newName: string) => { + const request = getModel("websocket_request", activeRequestId); + if (request == null) return false; + + const patch = renamePathPlaceholder(request, oldName, newName); + if (patch == null) return false; // Unusable name, so the editor reverts the field + void patchModel(request, patch); + return true; + }, + [activeRequestId], + ); + + const { urlParameterPairs, urlParametersKey } = useMemo( + () => + derivePathPlaceholderPairs( + activeRequest.url, + activeRequest.urlParameters, + handleRenamePathPlaceholder, + ), + [activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder], + ); const tabs = useMemo(() => { return [ diff --git a/apps/yaak-client/components/core/PairEditor.tsx b/apps/yaak-client/components/core/PairEditor.tsx index f16fbc04..be5f85f5 100644 --- a/apps/yaak-client/components/core/PairEditor.tsx +++ b/apps/yaak-client/components/core/PairEditor.tsx @@ -36,10 +36,19 @@ import type { RadioDropdownItem } from "./RadioDropdown"; import { RadioDropdown } from "./RadioDropdown"; export interface PairEditorHandle { + /** + * Focus a row's name field once it's able to take focus. Focus can't land immediately when the + * row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming + * active — so this retries for up to ~1s. A newer focus request cancels a pending one. + */ focusName(id: string): void; + /** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */ focusValue(id: string): void; } +/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */ +const MAX_FOCUS_ATTEMPTS = 60; + export type PairEditorProps = { allowFileValues?: boolean; allowMultilineValues?: boolean; @@ -53,7 +62,7 @@ export type PairEditorProps = { nameValidate?: InputProps["validate"]; noScroll?: boolean; onChange: (pairs: PairWithId[]) => void; - pairs: Pair[]; + pairs: EditablePair[]; stateKey: InputProps["stateKey"]; setRef?: (n: PairEditorHandle) => void; valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; @@ -72,13 +81,35 @@ export type Pair = { contentType?: string; filename?: string; isFile?: boolean; - readOnlyName?: boolean; }; export type PairWithId = Pair & { id: string; }; +/** + * A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain + * `Pair` stays the shape that gets written to models. + */ +export type EditablePair = Pair & { + /** + * When set, name edits are held until the field blurs and then committed through this, instead + * of calling `onChange` on every keystroke. Return false to reject the new name, which reverts + * the field. For names that can't be written directly, like a URL path placeholder that lives + * in the URL itself. + */ + commitName?: (name: string) => boolean; +}; + +type EditablePairWithId = EditablePair & { + id: string; +}; + +/** Strip the editor-only fields, so they can never reach a model write */ +function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId { + return pair; +} + /** Max number of pairs to show before prompting the user to reveal the rest */ const MAX_INITIAL_PAIRS = 30; @@ -106,8 +137,8 @@ export function PairEditor({ setRef, }: PairEditorProps) { const [hoveredIndex, setHoveredIndex] = useState(null); - const [isDragging, setIsDragging] = useState(null); - const [pairs, setPairs] = useState([]); + const [isDragging, setIsDragging] = useState(null); + const [pairs, setPairs] = useState([]); const [showAll, toggleShowAll] = useToggle(false); // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale. @@ -115,16 +146,38 @@ export function PairEditor({ const rowsRef = useRef>({}); + const pendingFocusFrame = useRef(null); + useEffect( + () => () => { + if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); + }, + [], + ); + + const focusWhenReady = useCallback((id: string, field: "name" | "value") => { + if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); + + let attemptsLeft = MAX_FOCUS_ATTEMPTS; + const attempt = () => { + pendingFocusFrame.current = null; + const row = rowsRef.current[id]; + const landed = field === "name" ? row?.focusName() : row?.focusValue(); + if (landed || --attemptsLeft <= 0) return; + pendingFocusFrame.current = requestAnimationFrame(attempt); + }; + attempt(); + }, []); + const handle = useMemo( () => ({ focusName(id: string) { - rowsRef.current[id]?.focusName(); + focusWhenReady(id, "name"); }, focusValue(id: string) { - rowsRef.current[id]?.focusValue(); + focusWhenReady(id, "value"); }, }), - [], + [focusWhenReady], ); const initPairEditorRow = useCallback( @@ -147,7 +200,7 @@ export function PairEditor({ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey useEffect(() => { // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs) - const newPairs: PairWithId[] = []; + const newPairs: EditablePairWithId[] = []; for (let i = 0; i < originalPairs.length; i++) { const p = originalPairs[i]; if (!p) continue; // Make TS happy @@ -155,6 +208,21 @@ export function PairEditor({ newPairs.push(ensurePairId(p)); } + // When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps + // every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed + // themselves when `defaultValue` changes, and a rebuild would drop the user's focus and + // selection — like tabbing from a placeholder's name into its value. + const trailingPair = pairs[pairs.length - 1]; + const sameRows = + trailingPair != null && + isPairEmpty(trailingPair) && + pairs.length === newPairs.length + 1 && + newPairs.every((p, i) => p.id === pairs[i]?.id); + if (sameRows) { + setPairs([...newPairs, trailingPair]); + return; + } + // Add empty last pair if there is none const lastPair = newPairs[newPairs.length - 1]; if (lastPair == null || !isPairEmpty(lastPair)) { @@ -166,10 +234,10 @@ export function PairEditor({ }, [forceUpdateKey]); const setPairsAndSave = useCallback( - (fn: (pairs: PairWithId[]) => PairWithId[]) => { + (fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => { setPairs((oldPairs) => { const pairs = fn(oldPairs); - onChange(pairs); + onChange(pairs.map(toPairData)); return pairs; }); }, @@ -177,7 +245,7 @@ export function PairEditor({ ); const handleChange = useCallback( - (pair: PairWithId) => + (pair: EditablePairWithId) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), [setPairsAndSave], ); @@ -362,14 +430,14 @@ export function PairEditor({ type PairEditorRowProps = { className?: string; - pair: PairWithId; + pair: EditablePairWithId; forceFocusNamePairId?: string | null; forceFocusValuePairId?: string | null; - onChange?: (pair: PairWithId) => void; - onDelete?: (pair: PairWithId, focusPrevious: boolean) => void; - onFocusName?: (pair: PairWithId) => void; - onFocusValue?: (pair: PairWithId) => void; - onSubmit?: (pair: PairWithId) => void; + onChange?: (pair: EditablePairWithId) => void; + onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void; + onFocusName?: (pair: EditablePairWithId) => void; + onFocusValue?: (pair: EditablePairWithId) => void; + onSubmit?: (pair: EditablePairWithId) => void; isLast?: boolean; disabled?: boolean; disableDrag?: boolean; @@ -397,8 +465,8 @@ type PairEditorRowProps = { >; interface RowHandle { - focusName(): void; - focusValue(): void; + focusName(): boolean; + focusValue(): boolean; } export function PairEditorRow({ @@ -436,9 +504,11 @@ export function PairEditorRow({ const handle = useRef({ focusName() { nameInputRef.current?.focus(); + return nameInputRef.current?.isFocused() ?? false; }, focusValue() { valueInputRef.current?.focus(); + return valueInputRef.current?.isFocused() ?? false; }, }); @@ -471,11 +541,37 @@ export function PairEditorRow({ [onChange, pair], ); + // The name being typed into a deferred-commit field, before it's committed or reverted + const pendingName = useRef(null); + const handleChangeName = useMemo( - () => (name: string) => onChange?.({ ...pair, name }), + () => (name: string) => { + // Keep the edit local until commit. Writing on every keystroke would reset the editor from + // beneath the cursor, since the pairs are derived from the name being edited. + if (pair.commitName != null) pendingName.current = name; + else onChange?.({ ...pair, name }); + }, [onChange, pair], ); + const revertName = useCallback(() => { + const nameInput = nameInputRef.current; + if (nameInput != null) { + const changes = { from: 0, to: nameInput.value().length, insert: pair.name }; + nameInput.dispatch({ changes }); + } + pendingName.current = null; + }, [pair.name]); + + const handleBlurName = useCallback(() => { + if (pair.commitName == null) return; + + const name = pendingName.current; + pendingName.current = null; + if (name == null || name === pair.name) return; + if (!pair.commitName(name)) revertName(); + }, [pair, revertName]); + const handleChangeValueText = useMemo( () => (value: string) => onChange?.({ ...pair, value, isFile: false }), [onChange, pair], @@ -596,7 +692,7 @@ export function PairEditorRow({ stateKey={`name.${pair.id}.${stateKey}`} disabled={disabled} wrapLines={false} - readOnly={pair.readOnlyName || isDraggingGlobal} + readOnly={isDraggingGlobal} size="sm" required={!isLast && !!pair.enabled && !!pair.value} validate={nameValidate} @@ -606,6 +702,7 @@ export function PairEditorRow({ defaultValue={pair.name} label="Name" name={`name[${index}]`} + onBlur={handleBlurName} onChange={handleChangeName} onFocus={handleFocusName} placeholder={namePlaceholder ?? "name"} @@ -808,7 +905,7 @@ function FileActionsDropdown({ ); } -function emptyPair(): PairWithId { +function emptyPair(): EditablePairWithId { return ensurePairId({ enabled: true, name: "", value: "" }); } diff --git a/apps/yaak-client/components/core/PairEditor.util.tsx b/apps/yaak-client/components/core/PairEditor.util.tsx index f596883b..27feb01e 100644 --- a/apps/yaak-client/components/core/PairEditor.util.tsx +++ b/apps/yaak-client/components/core/PairEditor.util.tsx @@ -1,9 +1,10 @@ import { generateId } from "../../lib/generateId"; import type { Pair, PairWithId } from "./PairEditor"; -export function ensurePairId(p: Pair): PairWithId { +// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable) +export function ensurePairId(p: T): T & PairWithId { if (typeof p.id === "string") { - return p as PairWithId; + return p as T & PairWithId; } return { ...p, id: p.id ?? generateId() }; } diff --git a/apps/yaak-client/lib/pathPlaceholders.test.ts b/apps/yaak-client/lib/pathPlaceholders.test.ts index 3c74b315..8130fbe7 100644 --- a/apps/yaak-client/lib/pathPlaceholders.test.ts +++ b/apps/yaak-client/lib/pathPlaceholders.test.ts @@ -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(); + }); +}); diff --git a/apps/yaak-client/lib/pathPlaceholders.ts b/apps/yaak-client/lib/pathPlaceholders.ts index f6dab4de..8cabab47 100644 --- a/apps/yaak-client/lib/pathPlaceholders.ts +++ b/apps/yaak-client/lib/pathPlaceholders.ts @@ -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, "\\$&"); +}