mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
Allow renaming URL path parameters from the Params tab (#528)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
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 type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
@@ -19,7 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|||||||
import { deepEqualAtom } from "../lib/atoms";
|
import { deepEqualAtom } from "../lib/atoms";
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { generateId } from "../lib/generateId";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||||
import {
|
import {
|
||||||
BODY_TYPE_BINARY,
|
BODY_TYPE_BINARY,
|
||||||
@@ -42,7 +42,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|||||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { InlineCode } from "@yaakapp-internal/ui";
|
import { InlineCode } from "@yaakapp-internal/ui";
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||||
@@ -133,20 +132,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
// value detaches from the placeholder.
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||||
for (const name of placeholderNames) {
|
// patch its parameter list back over newer edits.
|
||||||
const item = items.find((p) => p.name === name);
|
const handleRenamePathPlaceholder = useCallback(
|
||||||
if (item) {
|
(oldName: string, newName: string) => {
|
||||||
item.readOnlyName = true;
|
const request = getModel("http_request", activeRequestId);
|
||||||
} else {
|
if (request == null) return false;
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||||
}
|
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
void patchModel(request, patch);
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
return true;
|
||||||
|
},
|
||||||
|
[activeRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||||
|
() =>
|
||||||
|
derivePathPlaceholderPairs(
|
||||||
|
activeRequest.url,
|
||||||
|
activeRequest.urlParameters,
|
||||||
|
handleRenamePathPlaceholder,
|
||||||
|
),
|
||||||
|
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||||
|
);
|
||||||
|
|
||||||
let numParams = 0;
|
let numParams = 0;
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
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";
|
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forceUpdateKey: string;
|
forceUpdateKey: string;
|
||||||
pairs: HttpRequest["headers"];
|
pairs: EditablePair[];
|
||||||
stateKey: PairEditorProps["stateKey"];
|
stateKey: PairEditorProps["stateKey"];
|
||||||
onChange: (headers: HttpRequest["urlParameters"]) => void;
|
onChange: PairEditorProps["onChange"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
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 type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -20,8 +20,7 @@ import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEdit
|
|||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
import { deepEqualAtom } from "../lib/atoms";
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
@@ -29,7 +28,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
|||||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } 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(() => {
|
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
// value detaches from the placeholder.
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||||
for (const name of placeholderNames) {
|
// patch its parameter list back over newer edits.
|
||||||
const item = items.find((p) => p.name === name);
|
const handleRenamePathPlaceholder = useCallback(
|
||||||
if (item) {
|
(oldName: string, newName: string) => {
|
||||||
item.readOnlyName = true;
|
const request = getModel("websocket_request", activeRequestId);
|
||||||
} else {
|
if (request == null) return false;
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||||
}
|
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
void patchModel(request, patch);
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
return true;
|
||||||
|
},
|
||||||
|
[activeRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||||
|
() =>
|
||||||
|
derivePathPlaceholderPairs(
|
||||||
|
activeRequest.url,
|
||||||
|
activeRequest.urlParameters,
|
||||||
|
handleRenamePathPlaceholder,
|
||||||
|
),
|
||||||
|
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||||
|
);
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
const tabs = useMemo<TabItem[]>(() => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -36,10 +36,19 @@ import type { RadioDropdownItem } from "./RadioDropdown";
|
|||||||
import { RadioDropdown } from "./RadioDropdown";
|
import { RadioDropdown } from "./RadioDropdown";
|
||||||
|
|
||||||
export interface PairEditorHandle {
|
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;
|
focusName(id: string): void;
|
||||||
|
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
|
||||||
focusValue(id: string): void;
|
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 = {
|
export type PairEditorProps = {
|
||||||
allowFileValues?: boolean;
|
allowFileValues?: boolean;
|
||||||
allowMultilineValues?: boolean;
|
allowMultilineValues?: boolean;
|
||||||
@@ -53,7 +62,7 @@ export type PairEditorProps = {
|
|||||||
nameValidate?: InputProps["validate"];
|
nameValidate?: InputProps["validate"];
|
||||||
noScroll?: boolean;
|
noScroll?: boolean;
|
||||||
onChange: (pairs: PairWithId[]) => void;
|
onChange: (pairs: PairWithId[]) => void;
|
||||||
pairs: Pair[];
|
pairs: EditablePair[];
|
||||||
stateKey: InputProps["stateKey"];
|
stateKey: InputProps["stateKey"];
|
||||||
setRef?: (n: PairEditorHandle) => void;
|
setRef?: (n: PairEditorHandle) => void;
|
||||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||||
@@ -72,13 +81,35 @@ export type Pair = {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
isFile?: boolean;
|
isFile?: boolean;
|
||||||
readOnlyName?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PairWithId = Pair & {
|
export type PairWithId = Pair & {
|
||||||
id: string;
|
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 */
|
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||||
const MAX_INITIAL_PAIRS = 30;
|
const MAX_INITIAL_PAIRS = 30;
|
||||||
|
|
||||||
@@ -106,8 +137,8 @@ export function PairEditor({
|
|||||||
setRef,
|
setRef,
|
||||||
}: PairEditorProps) {
|
}: PairEditorProps) {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
|
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
|
||||||
const [pairs, setPairs] = useState<PairWithId[]>([]);
|
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
|
||||||
const [showAll, toggleShowAll] = useToggle(false);
|
const [showAll, toggleShowAll] = useToggle(false);
|
||||||
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
|
// 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.
|
// 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<Record<string, RowHandle | null>>({});
|
const rowsRef = useRef<Record<string, RowHandle | null>>({});
|
||||||
|
|
||||||
|
const pendingFocusFrame = useRef<number | null>(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<PairEditorHandle>(
|
const handle = useMemo<PairEditorHandle>(
|
||||||
() => ({
|
() => ({
|
||||||
focusName(id: string) {
|
focusName(id: string) {
|
||||||
rowsRef.current[id]?.focusName();
|
focusWhenReady(id, "name");
|
||||||
},
|
},
|
||||||
focusValue(id: string) {
|
focusValue(id: string) {
|
||||||
rowsRef.current[id]?.focusValue();
|
focusWhenReady(id, "value");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[focusWhenReady],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initPairEditorRow = useCallback(
|
const initPairEditorRow = useCallback(
|
||||||
@@ -147,7 +200,7 @@ export function PairEditor({
|
|||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
|
// 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++) {
|
for (let i = 0; i < originalPairs.length; i++) {
|
||||||
const p = originalPairs[i];
|
const p = originalPairs[i];
|
||||||
if (!p) continue; // Make TS happy
|
if (!p) continue; // Make TS happy
|
||||||
@@ -155,6 +208,21 @@ export function PairEditor({
|
|||||||
newPairs.push(ensurePairId(p));
|
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
|
// Add empty last pair if there is none
|
||||||
const lastPair = newPairs[newPairs.length - 1];
|
const lastPair = newPairs[newPairs.length - 1];
|
||||||
if (lastPair == null || !isPairEmpty(lastPair)) {
|
if (lastPair == null || !isPairEmpty(lastPair)) {
|
||||||
@@ -166,10 +234,10 @@ export function PairEditor({
|
|||||||
}, [forceUpdateKey]);
|
}, [forceUpdateKey]);
|
||||||
|
|
||||||
const setPairsAndSave = useCallback(
|
const setPairsAndSave = useCallback(
|
||||||
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
|
||||||
setPairs((oldPairs) => {
|
setPairs((oldPairs) => {
|
||||||
const pairs = fn(oldPairs);
|
const pairs = fn(oldPairs);
|
||||||
onChange(pairs);
|
onChange(pairs.map(toPairData));
|
||||||
return pairs;
|
return pairs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -177,7 +245,7 @@ export function PairEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(pair: PairWithId) =>
|
(pair: EditablePairWithId) =>
|
||||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||||
[setPairsAndSave],
|
[setPairsAndSave],
|
||||||
);
|
);
|
||||||
@@ -362,14 +430,14 @@ export function PairEditor({
|
|||||||
|
|
||||||
type PairEditorRowProps = {
|
type PairEditorRowProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
pair: PairWithId;
|
pair: EditablePairWithId;
|
||||||
forceFocusNamePairId?: string | null;
|
forceFocusNamePairId?: string | null;
|
||||||
forceFocusValuePairId?: string | null;
|
forceFocusValuePairId?: string | null;
|
||||||
onChange?: (pair: PairWithId) => void;
|
onChange?: (pair: EditablePairWithId) => void;
|
||||||
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
|
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
|
||||||
onFocusName?: (pair: PairWithId) => void;
|
onFocusName?: (pair: EditablePairWithId) => void;
|
||||||
onFocusValue?: (pair: PairWithId) => void;
|
onFocusValue?: (pair: EditablePairWithId) => void;
|
||||||
onSubmit?: (pair: PairWithId) => void;
|
onSubmit?: (pair: EditablePairWithId) => void;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
disableDrag?: boolean;
|
disableDrag?: boolean;
|
||||||
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
interface RowHandle {
|
interface RowHandle {
|
||||||
focusName(): void;
|
focusName(): boolean;
|
||||||
focusValue(): void;
|
focusValue(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairEditorRow({
|
export function PairEditorRow({
|
||||||
@@ -436,9 +504,11 @@ export function PairEditorRow({
|
|||||||
const handle = useRef<RowHandle>({
|
const handle = useRef<RowHandle>({
|
||||||
focusName() {
|
focusName() {
|
||||||
nameInputRef.current?.focus();
|
nameInputRef.current?.focus();
|
||||||
|
return nameInputRef.current?.isFocused() ?? false;
|
||||||
},
|
},
|
||||||
focusValue() {
|
focusValue() {
|
||||||
valueInputRef.current?.focus();
|
valueInputRef.current?.focus();
|
||||||
|
return valueInputRef.current?.isFocused() ?? false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -471,11 +541,37 @@ export function PairEditorRow({
|
|||||||
[onChange, pair],
|
[onChange, pair],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The name being typed into a deferred-commit field, before it's committed or reverted
|
||||||
|
const pendingName = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleChangeName = useMemo(
|
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],
|
[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(
|
const handleChangeValueText = useMemo(
|
||||||
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
||||||
[onChange, pair],
|
[onChange, pair],
|
||||||
@@ -596,7 +692,7 @@ export function PairEditorRow({
|
|||||||
stateKey={`name.${pair.id}.${stateKey}`}
|
stateKey={`name.${pair.id}.${stateKey}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
wrapLines={false}
|
wrapLines={false}
|
||||||
readOnly={pair.readOnlyName || isDraggingGlobal}
|
readOnly={isDraggingGlobal}
|
||||||
size="sm"
|
size="sm"
|
||||||
required={!isLast && !!pair.enabled && !!pair.value}
|
required={!isLast && !!pair.enabled && !!pair.value}
|
||||||
validate={nameValidate}
|
validate={nameValidate}
|
||||||
@@ -606,6 +702,7 @@ export function PairEditorRow({
|
|||||||
defaultValue={pair.name}
|
defaultValue={pair.name}
|
||||||
label="Name"
|
label="Name"
|
||||||
name={`name[${index}]`}
|
name={`name[${index}]`}
|
||||||
|
onBlur={handleBlurName}
|
||||||
onChange={handleChangeName}
|
onChange={handleChangeName}
|
||||||
onFocus={handleFocusName}
|
onFocus={handleFocusName}
|
||||||
placeholder={namePlaceholder ?? "name"}
|
placeholder={namePlaceholder ?? "name"}
|
||||||
@@ -808,7 +905,7 @@ function FileActionsDropdown({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPair(): PairWithId {
|
function emptyPair(): EditablePairWithId {
|
||||||
return ensurePairId({ enabled: true, name: "", value: "" });
|
return ensurePairId({ enabled: true, name: "", value: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { generateId } from "../../lib/generateId";
|
import { generateId } from "../../lib/generateId";
|
||||||
import type { Pair, PairWithId } from "./PairEditor";
|
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<T extends Pair>(p: T): T & PairWithId {
|
||||||
if (typeof p.id === "string") {
|
if (typeof p.id === "string") {
|
||||||
return p as PairWithId;
|
return p as T & PairWithId;
|
||||||
}
|
}
|
||||||
return { ...p, id: p.id ?? generateId() };
|
return { ...p, id: p.id ?? generateId() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
import { extractPathPlaceholders } from "./pathPlaceholders";
|
import {
|
||||||
|
derivePathPlaceholderPairs,
|
||||||
|
extractPathPlaceholders,
|
||||||
|
renamePathPlaceholder,
|
||||||
|
} from "./pathPlaceholders";
|
||||||
|
|
||||||
describe("extractPathPlaceholders", () => {
|
describe("extractPathPlaceholders", () => {
|
||||||
test("extracts a single placeholder", () => {
|
test("extracts a single placeholder", () => {
|
||||||
@@ -26,3 +30,185 @@ describe("extractPathPlaceholders", () => {
|
|||||||
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
|
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.
|
* Extract `:name`-style path placeholders from a URL string.
|
||||||
*
|
*
|
||||||
@@ -12,3 +15,88 @@
|
|||||||
export function extractPathPlaceholders(url: string): string[] {
|
export function extractPathPlaceholders(url: string): string[] {
|
||||||
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
|
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