mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Merge remote-tracking branch 'origin/main' into stetta/tk_mwaxu5pizxqxbmzj
# Conflicts: # apps/yaak-proxy/components/FlowCard.tsx
This commit is contained in:
commit
a4355b956a
36 files changed
+1875
-218
No files matched your search
@@ -6,7 +6,7 @@ import type {
|
|||||||
Workspace,
|
Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
import { patchModel } from "@yaakapp-internal/models";
|
||||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||||
@@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton";
|
|||||||
import { Input, type InputProps } from "./core/Input";
|
import { Input, type InputProps } from "./core/Input";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
import { RadioDropdown } from "./core/RadioDropdown";
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
|
||||||
import { DynamicForm } from "./DynamicForm";
|
import { DynamicForm } from "./DynamicForm";
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import {
|
|||||||
type ImportSource,
|
type ImportSource,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
|
import {
|
||||||
|
Banner,
|
||||||
|
FormattedError,
|
||||||
|
HStack,
|
||||||
|
Icon,
|
||||||
|
VStack,
|
||||||
|
IconTooltip,
|
||||||
|
SegmentedControl,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { errorMessage } from "../lib/errorMessage";
|
import { errorMessage } from "../lib/errorMessage";
|
||||||
@@ -22,7 +30,6 @@ import { CheckboxTree } from "./core/CheckboxTree";
|
|||||||
import { Chip } from "./core/Chip";
|
import { Chip } from "./core/Chip";
|
||||||
import { DialogFooter } from "./core/Dialog";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { Select } from "./core/Select";
|
import { Select } from "./core/Select";
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
|
||||||
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { SegmentedControl } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import type { EditorProps } from "./core/Editor/Editor";
|
import type { EditorProps } from "./core/Editor/Editor";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
|
||||||
import { Markdown } from "./Markdown";
|
import { Markdown } from "./Markdown";
|
||||||
|
|
||||||
type ViewMode = "edit" | "preview";
|
type ViewMode = "edit" | "preview";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||||
import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { appInfo } from "../../lib/appInfo";
|
import { appInfo } from "../../lib/appInfo";
|
||||||
import { rpc } from "../../lib/rpc";
|
import { rpc } from "../../lib/rpc";
|
||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
import { SegmentedControl } from "../core/SegmentedControl";
|
|
||||||
import {
|
import {
|
||||||
SettingCommand,
|
SettingCommand,
|
||||||
SettingSnippet,
|
SettingSnippet,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { flowPrettyView, shownViewMode } from "./FlowBodyViewer";
|
||||||
|
|
||||||
|
// The package's index reaches for the window as it loads; the view rule is all this needs.
|
||||||
|
vi.mock("@yaakapp-internal/ui", async () => {
|
||||||
|
const { prettyViewFor } = await vi.importActual<
|
||||||
|
typeof import("../../../packages/ui/src/components/http/BodyViewModeSwitch")
|
||||||
|
>("../../../packages/ui/src/components/http/BodyViewModeSwitch");
|
||||||
|
return { prettyViewFor, LARGE_BODY_BYTES: 2_000_000 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = (mime: string, language: string | null, which: "request" | "response" = "response") =>
|
||||||
|
flowPrettyView({
|
||||||
|
which,
|
||||||
|
mime,
|
||||||
|
text: "x",
|
||||||
|
binary: false,
|
||||||
|
language: language as never,
|
||||||
|
hasForm: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flowPrettyView", () => {
|
||||||
|
test("names the formatted view per format, the same on both halves", () => {
|
||||||
|
for (const which of ["request", "response"] as const) {
|
||||||
|
expect(text("application/json", "json", which)).toBe("formatted");
|
||||||
|
expect(text("text/csv", "text", which)).toBe("table");
|
||||||
|
expect(text("image/svg+xml", "xml", which)).toBe("preview");
|
||||||
|
}
|
||||||
|
expect(text("text/plain", "text")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HTML previews as a response and formats as a request", () => {
|
||||||
|
expect(text("text/html", "html", "response")).toBe("preview");
|
||||||
|
expect(text("text/html", "html", "request")).toBe("formatted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multipart has parts whether or not it reads as text", () => {
|
||||||
|
const base = { which: "request" as const, mime: "multipart/form-data", language: null };
|
||||||
|
expect(flowPrettyView({ ...base, text: null, binary: false, hasForm: false })).toBe("parts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a URL-encoded form's fields are its formatted view, where the tab has them", () => {
|
||||||
|
const base = {
|
||||||
|
which: "request" as const,
|
||||||
|
mime: "application/x-www-form-urlencoded",
|
||||||
|
text: "a=1",
|
||||||
|
binary: false,
|
||||||
|
language: "text" as const,
|
||||||
|
};
|
||||||
|
expect(flowPrettyView({ ...base, hasForm: true })).toBe("form");
|
||||||
|
expect(flowPrettyView({ ...base, hasForm: false })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("images, binary and undecodable bodies have one view", () => {
|
||||||
|
const base = { which: "response" as const, language: null, hasForm: false };
|
||||||
|
expect(flowPrettyView({ ...base, mime: "image/png", text: null, binary: true })).toBeNull();
|
||||||
|
expect(
|
||||||
|
flowPrettyView({ ...base, mime: "application/json", text: null, binary: true }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
flowPrettyView({ ...base, mime: "application/octet-stream", text: null, binary: false }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shownViewMode", () => {
|
||||||
|
test("follows the preference", () => {
|
||||||
|
expect(shownViewMode({ key: "a", large: false, preferred: "pretty", chosen: null })).toBe(
|
||||||
|
"pretty",
|
||||||
|
);
|
||||||
|
expect(shownViewMode({ key: "a", large: false, preferred: "raw", chosen: null })).toBe("raw");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a large body opens raw, and formats once asked, for that body alone", () => {
|
||||||
|
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen: null })).toBe("raw");
|
||||||
|
const chosen = { key: "a", mode: "pretty" as const };
|
||||||
|
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen })).toBe("pretty");
|
||||||
|
expect(shownViewMode({ key: "b", large: true, preferred: "pretty", chosen })).toBe("raw");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,22 +2,32 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||||
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||||
import {
|
import {
|
||||||
|
ACTIONS_BAR_CONTAINER,
|
||||||
|
ActionsBar,
|
||||||
Banner,
|
Banner,
|
||||||
|
BodyViewModeSwitch,
|
||||||
Button,
|
Button,
|
||||||
CsvViewer,
|
CsvViewer,
|
||||||
HexdumpViewer,
|
HexdumpViewer,
|
||||||
Icon,
|
Icon,
|
||||||
ImageViewer,
|
ImageViewer,
|
||||||
|
LARGE_BODY_BYTES,
|
||||||
languageFromContentType,
|
languageFromContentType,
|
||||||
LoadingIcon,
|
LoadingIcon,
|
||||||
MultipartViewer,
|
MultipartViewer,
|
||||||
|
type PrettyView,
|
||||||
|
prettyViewFor,
|
||||||
|
type ResponseViewMode,
|
||||||
SvgViewer,
|
SvgViewer,
|
||||||
TextViewer,
|
TextViewer,
|
||||||
WebPageViewer,
|
WebPageViewer,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { bodyViewModeAtom } from "../lib/atoms";
|
||||||
import {
|
import {
|
||||||
bodyAsText,
|
bodyAsText,
|
||||||
bodyImageType,
|
bodyImageType,
|
||||||
@@ -33,15 +43,97 @@ import { useSaveFlowBody } from "../lib/flowActions";
|
|||||||
interface Props {
|
interface Props {
|
||||||
flow: Flow;
|
flow: Flow;
|
||||||
which: FlowBody;
|
which: FlowBody;
|
||||||
pretty: boolean;
|
/** A URL-encoded body's fields, its formatted view, with the switch in the fields' own bar */
|
||||||
|
form?: (actions: ReactNode) => ReactNode;
|
||||||
|
/** Leaves saying the body was cut short to the tab, which says it its own way */
|
||||||
|
hideCutNote?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
|
export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
|
||||||
return useQuery({ ...flowBodyQueryOptions(flow, which), enabled });
|
return useQuery({ ...flowBodyQueryOptions(flow, which), enabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
/** A mode chosen for one large body, which holds for it alone */
|
||||||
|
interface ChosenViewMode {
|
||||||
|
key: string;
|
||||||
|
mode: ResponseViewMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mode a body shows in: the preference, except raw for a large one until chosen for it */
|
||||||
|
export function shownViewMode({
|
||||||
|
key,
|
||||||
|
large,
|
||||||
|
preferred,
|
||||||
|
chosen,
|
||||||
|
}: {
|
||||||
|
key: string;
|
||||||
|
large: boolean;
|
||||||
|
preferred: ResponseViewMode;
|
||||||
|
chosen: ChosenViewMode | null;
|
||||||
|
}): ResponseViewMode {
|
||||||
|
if (chosen?.key === key) return chosen.mode;
|
||||||
|
return large ? "raw" : preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted or raw, as last chosen, except that a body big enough to slow the card opens raw,
|
||||||
|
* and choosing for it holds for that body alone
|
||||||
|
*/
|
||||||
|
export function useBodyViewMode(
|
||||||
|
key: string,
|
||||||
|
large: boolean,
|
||||||
|
): [ResponseViewMode, (mode: ResponseViewMode) => void] {
|
||||||
|
const [preferred, setPreferred] = useAtom(bodyViewModeAtom);
|
||||||
|
const [chosen, setChosen] = useState<ChosenViewMode | null>(null);
|
||||||
|
const mode = shownViewMode({ key, large, preferred, chosen });
|
||||||
|
const setMode = useCallback(
|
||||||
|
(next: ResponseViewMode) => {
|
||||||
|
if (large) {
|
||||||
|
setChosen({ key, mode: next });
|
||||||
|
} else {
|
||||||
|
setChosen(null);
|
||||||
|
setPreferred(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[key, large, setPreferred],
|
||||||
|
);
|
||||||
|
return [mode, setMode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The formatted view a body has, or null when it has only the one: parts for multipart, the
|
||||||
|
* fields for a URL-encoded form, or else what the shared rule gives its text. Images and binary
|
||||||
|
* bodies have only their one view.
|
||||||
|
*/
|
||||||
|
export function flowPrettyView({
|
||||||
|
which,
|
||||||
|
mime,
|
||||||
|
text,
|
||||||
|
binary,
|
||||||
|
language,
|
||||||
|
hasForm,
|
||||||
|
}: {
|
||||||
|
which: FlowBody;
|
||||||
|
mime: string;
|
||||||
|
text: string | null;
|
||||||
|
/** An image, or what failed to decode */
|
||||||
|
binary: boolean;
|
||||||
|
language: CodeEditorProps["language"] | null;
|
||||||
|
hasForm: boolean;
|
||||||
|
}): PrettyView | null {
|
||||||
|
if (binary) return null;
|
||||||
|
if (mime.startsWith("multipart/")) return "parts";
|
||||||
|
if (text == null) return null;
|
||||||
|
if (hasForm && mime === "application/x-www-form-urlencoded") return "form";
|
||||||
|
return prettyViewFor({ mimeType: mime, language, previewHtml: which === "response" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props) {
|
||||||
const body = useFlowBody(flow, which);
|
const body = useFlowBody(flow, which);
|
||||||
|
const [mode, setMode] = useBodyViewMode(
|
||||||
|
`${flow.id}.${which}`,
|
||||||
|
(body.data?.bytes.length ?? 0) > LARGE_BODY_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
if (body.isPending) {
|
if (body.isPending) {
|
||||||
return (
|
return (
|
||||||
@@ -69,12 +161,28 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
|||||||
const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null;
|
const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null;
|
||||||
const text =
|
const text =
|
||||||
decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null;
|
decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null;
|
||||||
|
const language = text == null ? null : languageFromContentType(contentType, text);
|
||||||
|
const prettyView = flowPrettyView({
|
||||||
|
which,
|
||||||
|
mime,
|
||||||
|
text,
|
||||||
|
binary: decodeError != null || imageType != null,
|
||||||
|
language,
|
||||||
|
hasForm: form != null,
|
||||||
|
});
|
||||||
|
// The one view a body without a formatted one has is its raw one
|
||||||
|
const pretty = prettyView != null && mode === "pretty";
|
||||||
|
const modeSwitch =
|
||||||
|
prettyView == null ? null : (
|
||||||
|
<BodyViewModeSwitch pretty={prettyView} value={mode} onChange={setMode} />
|
||||||
|
);
|
||||||
|
|
||||||
let viewer: ReactNode;
|
let viewer: ReactNode;
|
||||||
|
let ownsBar = false;
|
||||||
if (decodeError != null) {
|
if (decodeError != null) {
|
||||||
// What failed to decode is as it was on the wire, compressed, so never text.
|
// What failed to decode is as it was on the wire, compressed, so never text.
|
||||||
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
||||||
} else if (mime.startsWith("multipart/") && pretty) {
|
} else if (prettyView === "parts" && pretty) {
|
||||||
const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
|
const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
|
||||||
viewer = (
|
viewer = (
|
||||||
<MultipartViewer
|
<MultipartViewer
|
||||||
@@ -88,22 +196,43 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
|||||||
viewer = <ImageBody bytes={bytes} mime={imageType} />;
|
viewer = <ImageBody bytes={bytes} mime={imageType} />;
|
||||||
} else if (text == null) {
|
} else if (text == null) {
|
||||||
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
||||||
} else if (mime.startsWith("image/svg")) {
|
} else if (prettyView === "form" && pretty && form != null) {
|
||||||
viewer = <SvgViewer text={text} />;
|
ownsBar = true;
|
||||||
} else if (/csv|tab-separated/.test(mime) && pretty) {
|
viewer = form(modeSwitch);
|
||||||
|
} else if (prettyView === "table" && pretty) {
|
||||||
viewer = <CsvViewer text={text} className="pb-2" />;
|
viewer = <CsvViewer text={text} className="pb-2" />;
|
||||||
|
} else if (prettyView === "preview" && pretty) {
|
||||||
|
viewer = mime.startsWith("image/svg") ? (
|
||||||
|
<SvgViewer text={text} />
|
||||||
|
) : (
|
||||||
|
<WebPageViewer html={text} baseUrl={flow.url} />
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const language = languageFromContentType(contentType, text);
|
ownsBar = true;
|
||||||
if (language === "html" && pretty && which === "response") {
|
viewer = (
|
||||||
viewer = <WebPageViewer html={text} baseUrl={flow.url} />;
|
<FlowTextViewer
|
||||||
} else {
|
flow={flow}
|
||||||
viewer = (
|
which={which}
|
||||||
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} />
|
text={text}
|
||||||
);
|
language={language}
|
||||||
}
|
pretty={pretty}
|
||||||
|
modeSwitch={modeSwitch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!truncated && decodeError == null) return viewer;
|
// The text viewer and the form draw the switch in their own bar, beside their own actions; the
|
||||||
|
// rest get a bar here. Each fills the tab content, so every bar has the same inset.
|
||||||
|
if (modeSwitch != null && !ownsBar) {
|
||||||
|
viewer = (
|
||||||
|
<div className={classNames(ACTIONS_BAR_CONTAINER, "h-full w-full")}>
|
||||||
|
{viewer}
|
||||||
|
<ActionsBar>{modeSwitch}</ActionsBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!truncated || hideCutNote) && decodeError == null) return viewer;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]">
|
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]">
|
||||||
@@ -112,7 +241,7 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
|||||||
{decodeError}. Showing the body as it was on the wire.
|
{decodeError}. Showing the body as it was on the wire.
|
||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
{truncated && (
|
{truncated && !hideCutNote && (
|
||||||
<Banner color="notice" className="text-sm mb-2">
|
<Banner color="notice" className="text-sm mb-2">
|
||||||
{wireTruncated || decodeError != null
|
{wireTruncated || decodeError != null
|
||||||
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
|
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
|
||||||
@@ -168,7 +297,16 @@ function FlowTextViewer({
|
|||||||
text,
|
text,
|
||||||
language,
|
language,
|
||||||
pretty,
|
pretty,
|
||||||
}: Props & { text: string; language: CodeEditorProps["language"] }) {
|
modeSwitch,
|
||||||
|
}: {
|
||||||
|
flow: Flow;
|
||||||
|
which: FlowBody;
|
||||||
|
text: string;
|
||||||
|
language: CodeEditorProps["language"];
|
||||||
|
pretty: boolean;
|
||||||
|
/** Formatted or raw, in the viewer's bar after its filter; null for a body with one view */
|
||||||
|
modeSwitch: ReactNode;
|
||||||
|
}) {
|
||||||
const runFilter = useCallback(
|
const runFilter = useCallback(
|
||||||
(expression: string) => filterText(flow.id, which, expression, language),
|
(expression: string) => filterText(flow.id, which, expression, language),
|
||||||
[flow.id, which, language],
|
[flow.id, which, language],
|
||||||
@@ -183,6 +321,8 @@ function FlowTextViewer({
|
|||||||
className="-mr-2 bg-surface"
|
className="-mr-2 bg-surface"
|
||||||
formatText={formatText}
|
formatText={formatText}
|
||||||
filterText={runFilter}
|
filterText={runFilter}
|
||||||
|
ownActionsBar
|
||||||
|
pinnedActions={modeSwitch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Dropdown,
|
Dropdown,
|
||||||
EmptyStateText,
|
EmptyStateText,
|
||||||
type ExtraPaneTab,
|
type ExtraPaneTab,
|
||||||
|
FormUrlencodedEditor,
|
||||||
type HttpBodyResult,
|
type HttpBodyResult,
|
||||||
HttpRequestPaneView,
|
HttpRequestPaneView,
|
||||||
HttpResponsePaneView,
|
HttpResponsePaneView,
|
||||||
@@ -13,14 +14,13 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
parseCookieHeaders,
|
parseCookieHeaders,
|
||||||
ResponseCookies,
|
ResponseCookies,
|
||||||
type ResponseViewMode,
|
|
||||||
SplitLayout,
|
SplitLayout,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes";
|
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
|
||||||
import { placeTabsAtom } from "../lib/atoms";
|
import { placeTabsAtom } from "../lib/atoms";
|
||||||
import { findHeader } from "../lib/body";
|
import { findHeader } from "../lib/body";
|
||||||
import {
|
import {
|
||||||
@@ -143,7 +143,6 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
|||||||
tab: {
|
tab: {
|
||||||
value: REQUEST_TAB_COOKIES,
|
value: REQUEST_TAB_COOKIES,
|
||||||
label: "Cookies",
|
label: "Cookies",
|
||||||
empty: sentCount === 0,
|
|
||||||
rightSlot: <CountBadge count={sentCount} />,
|
rightSlot: <CountBadge count={sentCount} />,
|
||||||
},
|
},
|
||||||
content: <ResponseCookies response={cookies} side="sent" />,
|
content: <ResponseCookies response={cookies} side="sent" />,
|
||||||
@@ -160,13 +159,16 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
|||||||
];
|
];
|
||||||
const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available);
|
const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available);
|
||||||
|
|
||||||
|
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HttpRequestPaneView
|
<HttpRequestPaneView
|
||||||
className="px-3"
|
// Padded below as the response half's tabs are, so their content, and the actions bar
|
||||||
|
// over it, sit the same distance from the card's edges
|
||||||
|
className="px-3 pb-3"
|
||||||
request={request}
|
request={request}
|
||||||
fullHeight
|
fullHeight
|
||||||
dimEmptyTabs
|
forceUpdateKey={forceUpdateKey}
|
||||||
forceUpdateKey={`${flow.updatedAt}.${body.data == null ? "" : "loaded"}`}
|
|
||||||
tabsStorageKey="proxy_request_tabs"
|
tabsStorageKey="proxy_request_tabs"
|
||||||
bodyLoader={loader}
|
bodyLoader={loader}
|
||||||
extraTabs={extraTabs}
|
extraTabs={extraTabs}
|
||||||
@@ -174,15 +176,30 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
|||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
urlLine={<UrlLine flow={flow} />}
|
urlLine={<UrlLine flow={flow} />}
|
||||||
renderBody={(editor) => (
|
renderBody={() => (
|
||||||
<FlowRequestBody
|
<FlowRequestBody
|
||||||
body={body}
|
body={body}
|
||||||
editor={editor}
|
// The response's viewer choice, with the form's fields as a URL-encoded body's
|
||||||
// The proxy's own viewer, with its hexdump and save, rather than a size alone
|
// formatted view, the switch in one bar with their bulk-view toggle
|
||||||
binaryViewer={
|
viewer={
|
||||||
request.bodyType === BODY_TYPE_BINARY ? (
|
<FlowBodyViewer
|
||||||
<FlowBodyViewer flow={flow} which="request" pretty />
|
flow={flow}
|
||||||
) : null
|
which="request"
|
||||||
|
form={
|
||||||
|
request.bodyType === BODY_TYPE_FORM_URLENCODED
|
||||||
|
? (actions) => (
|
||||||
|
<FormUrlencodedEditor
|
||||||
|
readOnly
|
||||||
|
requestId={request.id}
|
||||||
|
form={request.body.form}
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
hideCutNote
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
requestSize={flow.requestSize}
|
requestSize={flow.requestSize}
|
||||||
/>
|
/>
|
||||||
@@ -210,7 +227,6 @@ function UrlLine({ flow }: { flow: Flow }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) {
|
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) {
|
||||||
const [viewMode, setViewMode] = useState<ResponseViewMode>("pretty");
|
|
||||||
// A stream's body is its messages, read by the Messages tab; reading it whole again on every
|
// A stream's body is its messages, read by the Messages tab; reading it whole again on every
|
||||||
// message would only hold copies of it
|
// message would only hold copies of it
|
||||||
const loadBody = flow.streamKind == null;
|
const loadBody = flow.streamKind == null;
|
||||||
@@ -242,7 +258,6 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
|
|||||||
tab: {
|
tab: {
|
||||||
value: RESPONSE_TAB_MESSAGES,
|
value: RESPONSE_TAB_MESSAGES,
|
||||||
label: messagesLabel,
|
label: messagesLabel,
|
||||||
empty: flow.messageCount === 0,
|
|
||||||
rightSlot: (
|
rightSlot: (
|
||||||
<CountBadge
|
<CountBadge
|
||||||
count={flow.messageCount}
|
count={flow.messageCount}
|
||||||
@@ -273,23 +288,18 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
|
|||||||
<HttpResponsePaneView
|
<HttpResponsePaneView
|
||||||
bare
|
bare
|
||||||
requestShownApart
|
requestShownApart
|
||||||
dimEmptyTabs
|
|
||||||
className={classNames(className, "border-border-subtle")}
|
className={classNames(className, "border-border-subtle")}
|
||||||
response={response}
|
response={response}
|
||||||
body={loader}
|
body={loader}
|
||||||
requestBody={null}
|
requestBody={null}
|
||||||
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
|
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
tabsStorageKey="proxy_response_tabs"
|
tabsStorageKey="proxy_response_tabs"
|
||||||
activeTabKey={flow.id}
|
activeTabKey={flow.id}
|
||||||
defaultTab={defaultTab}
|
defaultTab={defaultTab}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
extraTabs={extraTabs}
|
extraTabs={extraTabs}
|
||||||
renderBodyViewer={() => (
|
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
|
||||||
<FlowBodyViewer flow={flow} which="response" pretty={viewMode === "pretty"} />
|
|
||||||
)}
|
|
||||||
statusLineCenter={
|
statusLineCenter={
|
||||||
flow.responseTruncated ? (
|
flow.responseTruncated ? (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ import {
|
|||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||||
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react";
|
import type {
|
||||||
|
CSSProperties,
|
||||||
|
KeyboardEvent,
|
||||||
|
MouseEvent,
|
||||||
|
ReactElement,
|
||||||
|
RefObject,
|
||||||
|
WheelEvent,
|
||||||
|
} from "react";
|
||||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
appliedFilterAtom,
|
appliedFilterAtom,
|
||||||
@@ -28,6 +35,7 @@ import {
|
|||||||
type Column,
|
type Column,
|
||||||
type ColumnId,
|
type ColumnId,
|
||||||
cellFilterTerm,
|
cellFilterTerm,
|
||||||
|
columnShifts,
|
||||||
customValue,
|
customValue,
|
||||||
gridTemplate,
|
gridTemplate,
|
||||||
gridWidth,
|
gridWidth,
|
||||||
@@ -65,7 +73,7 @@ import {
|
|||||||
import { useShowToast } from "../lib/toast";
|
import { useShowToast } from "../lib/toast";
|
||||||
import { AppIcon } from "./AppIcon";
|
import { AppIcon } from "./AppIcon";
|
||||||
import { FlowListEmpty, PausedRow } from "./FlowListEmpty";
|
import { FlowListEmpty, PausedRow } from "./FlowListEmpty";
|
||||||
import { FlowListHeader } from "./FlowListHeader";
|
import { type ColumnDragEvent, FlowListHeader } from "./FlowListHeader";
|
||||||
|
|
||||||
const BOTTOM_PADDING = 6;
|
const BOTTOM_PADDING = 6;
|
||||||
/** Rows from the top at which older flows start loading, or from the bottom, sorted, more */
|
/** Rows from the top at which older flows start loading, or from the bottom, sorted, more */
|
||||||
@@ -305,6 +313,11 @@ export function FlowList({ style, className }: Props) {
|
|||||||
el.scrollLeft += e.deltaX;
|
el.scrollLeft += e.deltaX;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A dragged column: its ghost, and the others sliding aside to make room for it
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||||
|
const onColumnDrag = useColumnDragView(rootRef, headerScrollRef, scrollRef);
|
||||||
|
|
||||||
// A list too short to scroll still loads older flows on scrolling up.
|
// A list too short to scroll still loads older flows on scrolling up.
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent<HTMLDivElement>) => {
|
(e: WheelEvent<HTMLDivElement>) => {
|
||||||
@@ -439,10 +452,11 @@ export function FlowList({ style, className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
"grid grid-rows-[auto_minmax(0,1fr)] grid-cols-[minmax(0,1fr)] min-h-0 text-sm",
|
"relative grid grid-rows-[auto_minmax(0,1fr)] grid-cols-[minmax(0,1fr)] min-h-0 text-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */}
|
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */}
|
||||||
@@ -462,11 +476,14 @@ export function FlowList({ style, className }: Props) {
|
|||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
onSetSort={handleSetSort}
|
onSetSort={handleSetSort}
|
||||||
onResetSort={follow}
|
onResetSort={follow}
|
||||||
|
scrollElement={getScrollElement}
|
||||||
|
onColumnDrag={onColumnDrag}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative min-h-0">
|
<div className="relative min-h-0">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
|
data-flow-rows
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label="Flows"
|
aria-label="Flows"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -576,6 +593,233 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
|
|||||||
}, [flows, following]);
|
}, [flows, following]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long the columns take to slide aside for a dragged column, or back */
|
||||||
|
const SLIDE_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a column's drag without rendering a row. Its ghost is its header and the cells of the
|
||||||
|
* rows in view, copied once when the drag starts and following the pointer sideways. The column
|
||||||
|
* hides where it is, and the columns between it and where it would drop slide aside by its width
|
||||||
|
* to leave a gap there, all by one style element: a rule a column, header and cells together,
|
||||||
|
* which rows arriving meanwhile follow too. Dropped, the rules go as the order changes, in the
|
||||||
|
* same task, so nothing animates or jumps; put back, the columns slide home first.
|
||||||
|
*/
|
||||||
|
function useColumnDragView(
|
||||||
|
rootRef: RefObject<HTMLDivElement | null>,
|
||||||
|
headerRef: RefObject<HTMLDivElement | null>,
|
||||||
|
listRef: RefObject<HTMLDivElement | null>,
|
||||||
|
): (event: ColumnDragEvent) => void {
|
||||||
|
const drag = useRef<ColumnDragView | null>(null);
|
||||||
|
// Columns sliding home after a drag was put back, until they are there
|
||||||
|
const settling = useRef<{ style: HTMLStyleElement; timer: number } | null>(null);
|
||||||
|
|
||||||
|
const settle = useCallback(() => {
|
||||||
|
const s = settling.current;
|
||||||
|
settling.current = null;
|
||||||
|
if (s == null) return;
|
||||||
|
clearTimeout(s.timer);
|
||||||
|
s.style.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onColumnDrag = useCallback(
|
||||||
|
(event: ColumnDragEvent) => {
|
||||||
|
const d = drag.current;
|
||||||
|
switch (event.type) {
|
||||||
|
case "move":
|
||||||
|
d?.ghost?.move(event.x);
|
||||||
|
return;
|
||||||
|
case "target":
|
||||||
|
if (d == null) return;
|
||||||
|
d.to = event.to;
|
||||||
|
d.style.textContent = columnDragCss(d);
|
||||||
|
return;
|
||||||
|
case "end": {
|
||||||
|
drag.current = null;
|
||||||
|
if (d == null) return;
|
||||||
|
d.ghost?.remove();
|
||||||
|
if (event.dropped || d.to == null) {
|
||||||
|
d.style.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
d.to = null;
|
||||||
|
d.style.textContent = columnDragCss(d);
|
||||||
|
settling.current = { style: d.style, timer: window.setTimeout(settle, SLIDE_MS) };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "start": {
|
||||||
|
settle();
|
||||||
|
d?.ghost?.remove();
|
||||||
|
d?.style.remove();
|
||||||
|
drag.current = null;
|
||||||
|
const root = rootRef.current;
|
||||||
|
const header = headerRef.current;
|
||||||
|
const list = listRef.current;
|
||||||
|
if (root == null || header == null || list == null) return;
|
||||||
|
const cells = Array.from(
|
||||||
|
header.querySelectorAll<HTMLElement>("[data-flow-header] > [data-column]"),
|
||||||
|
);
|
||||||
|
const ids = cells.map((c) => c.dataset.column as ColumnId);
|
||||||
|
const from = ids.indexOf(event.id);
|
||||||
|
if (from === -1) return;
|
||||||
|
// Copied before it hides
|
||||||
|
const ghost = snapshotColumn(event.id, event.x, root, header, list);
|
||||||
|
const style = document.createElement("style");
|
||||||
|
document.head.append(style);
|
||||||
|
const widths = cells.map((c) => c.getBoundingClientRect().width);
|
||||||
|
drag.current = { ghost, ids, widths, from, to: null, style };
|
||||||
|
style.textContent = columnDragCss(drag.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rootRef, headerRef, listRef, settle],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
settle();
|
||||||
|
drag.current?.ghost?.remove();
|
||||||
|
drag.current?.style.remove();
|
||||||
|
drag.current = null;
|
||||||
|
},
|
||||||
|
[settle],
|
||||||
|
);
|
||||||
|
return onColumnDrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnDragView {
|
||||||
|
ghost: ColumnGhost | null;
|
||||||
|
/** The columns shown and their widths, the dragged one's place among them, and its target */
|
||||||
|
ids: ColumnId[];
|
||||||
|
widths: number[];
|
||||||
|
from: number;
|
||||||
|
to: number | null;
|
||||||
|
style: HTMLStyleElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rules for a drag: the column hidden, and those it passes slid aside */
|
||||||
|
function columnDragCss({ ids, widths, from, to }: ColumnDragView): string {
|
||||||
|
// A row's cell is the box in its grid: the column's child, or inside the `contents` span a
|
||||||
|
// tooltip wraps it in, as a box that is only its contents can't move
|
||||||
|
const cells = (is: string) =>
|
||||||
|
`[data-flow-rows] ${is} > :not(.contents), [data-flow-rows] ${is} > .contents > *`;
|
||||||
|
const column = (id: ColumnId) => {
|
||||||
|
const is = `[data-column="${CSS.escape(id)}"]`;
|
||||||
|
return `[data-flow-header] > ${is}, ${cells(is)}`;
|
||||||
|
};
|
||||||
|
const rules = [
|
||||||
|
`[data-flow-header] > [data-column], ${cells("[data-column]")} { transition: transform ${SLIDE_MS}ms ease; }`,
|
||||||
|
];
|
||||||
|
const dragged = ids[from];
|
||||||
|
if (dragged != null) rules.push(`${column(dragged)} { visibility: hidden; }`);
|
||||||
|
const shifts = columnShifts(ids.length, from, widths[from] ?? 0, to);
|
||||||
|
shifts.forEach((dx, i) => {
|
||||||
|
const id = ids[i];
|
||||||
|
if (dx !== 0 && id != null) rules.push(`${column(id)} { transform: translateX(${dx}px); }`);
|
||||||
|
});
|
||||||
|
// A failed flow's reason spans Type and Size together; while they part, it keeps to Type's
|
||||||
|
// width, so it doesn't slide over the column past Size.
|
||||||
|
const type = ids.indexOf("type");
|
||||||
|
if (type !== -1 && ids[type + 1] === "size") {
|
||||||
|
const size = type + 1;
|
||||||
|
if (from === size || shifts[type] !== shifts[size]) {
|
||||||
|
const is = '[data-flow-rows] [data-column="type"]';
|
||||||
|
rules.push(
|
||||||
|
`${is} > .col-span-2, ${is} > .contents > .col-span-2 { width: ${widths[type] ?? 0}px; }`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnGhost {
|
||||||
|
/** Follows the pointer, across the window */
|
||||||
|
move: (x: number) => void;
|
||||||
|
remove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotColumn(
|
||||||
|
id: ColumnId,
|
||||||
|
startX: number,
|
||||||
|
root: HTMLElement,
|
||||||
|
header: HTMLElement,
|
||||||
|
list: HTMLElement,
|
||||||
|
): ColumnGhost | null {
|
||||||
|
const cell = header.querySelector<HTMLElement>(`[data-column="${id}"]`);
|
||||||
|
if (cell == null) return null;
|
||||||
|
const rootBox = root.getBoundingClientRect();
|
||||||
|
const cellBox = cell.getBoundingClientRect();
|
||||||
|
const listBox = list.getBoundingClientRect();
|
||||||
|
const listHeight = list.clientHeight;
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.setAttribute("aria-hidden", "true");
|
||||||
|
el.className = classNames(
|
||||||
|
"pointer-events-none absolute top-0 z-20 overflow-hidden rounded-sm",
|
||||||
|
// Lifted: a shadow, and just enough opacity to read as held, not enough to show text through
|
||||||
|
"bg-surface border border-border-subtle shadow-lg opacity-95",
|
||||||
|
);
|
||||||
|
Object.assign(el.style, {
|
||||||
|
left: `${cellBox.left - rootBox.left}px`,
|
||||||
|
width: `${cellBox.width}px`,
|
||||||
|
height: `${rootBox.height}px`,
|
||||||
|
willChange: "transform",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The header's label, without its resize handle
|
||||||
|
const head = place(cellBox.top - rootBox.top, cellBox.height, "text-xs text-text-subtlest");
|
||||||
|
const label = cell.cloneNode(true) as HTMLElement;
|
||||||
|
for (const handle of label.querySelectorAll(".cursor-col-resize")) handle.remove();
|
||||||
|
label.style.width = "100%";
|
||||||
|
head.append(label);
|
||||||
|
el.append(head);
|
||||||
|
|
||||||
|
// The rows in view, clipped to the list as it shows them
|
||||||
|
const rows = document.createElement("div");
|
||||||
|
rows.className = "absolute left-0 right-0 overflow-hidden";
|
||||||
|
Object.assign(rows.style, { top: `${listBox.top - rootBox.top}px`, height: `${listHeight}px` });
|
||||||
|
for (const row of list.querySelectorAll<HTMLElement>('[role="option"]')) {
|
||||||
|
const rowBox = row.getBoundingClientRect();
|
||||||
|
if (rowBox.bottom <= listBox.top || rowBox.top >= listBox.top + listHeight) continue;
|
||||||
|
const source = row.querySelector(`[data-column="${id}"]`);
|
||||||
|
if (source == null) continue;
|
||||||
|
const copy = place(
|
||||||
|
rowBox.top - listBox.top,
|
||||||
|
rowBox.height,
|
||||||
|
classNames(
|
||||||
|
"whitespace-nowrap tabular-nums",
|
||||||
|
row.getAttribute("aria-selected") === "true" && "text-text bg-surface-highlight",
|
||||||
|
row.classList.contains("text-text-subtlest") && "text-text-subtlest",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const child of source.children) {
|
||||||
|
const clone = child.cloneNode(true) as HTMLElement;
|
||||||
|
clone.style.width = "100%";
|
||||||
|
copy.append(clone);
|
||||||
|
}
|
||||||
|
rows.append(copy);
|
||||||
|
}
|
||||||
|
el.append(rows);
|
||||||
|
root.append(el);
|
||||||
|
|
||||||
|
// Kept within the list's visible width
|
||||||
|
const min = listBox.left;
|
||||||
|
const max = Math.max(min, listBox.left + list.clientWidth - cellBox.width);
|
||||||
|
const move = (x: number) => {
|
||||||
|
const left = Math.min(max, Math.max(min, cellBox.left + x - startX));
|
||||||
|
el.style.transform = `translateX(${left - cellBox.left}px)`;
|
||||||
|
};
|
||||||
|
move(startX);
|
||||||
|
return { move, remove: () => el.remove() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line of the ghost, `top` pixels down its box */
|
||||||
|
function place(top: number, height: number, className: string): HTMLElement {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = classNames("absolute left-0 right-0 flex items-center", className);
|
||||||
|
Object.assign(el.style, { top: `${top}px`, height: `${height}px` });
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
interface RowProps {
|
interface RowProps {
|
||||||
flow: Flow;
|
flow: Flow;
|
||||||
index: number;
|
index: number;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
type DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useDraggable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
|
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -9,7 +17,16 @@ import {
|
|||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { memo, type MouseEvent, useCallback, useRef, useState } from "react";
|
import {
|
||||||
|
memo,
|
||||||
|
type MouseEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
addSortKey,
|
addSortKey,
|
||||||
allColumns,
|
allColumns,
|
||||||
@@ -19,9 +36,13 @@ import {
|
|||||||
type ColumnPrefs,
|
type ColumnPrefs,
|
||||||
type CustomColumnId,
|
type CustomColumnId,
|
||||||
canHide,
|
canHide,
|
||||||
|
dropBoundary,
|
||||||
hideOtherColumns,
|
hideOtherColumns,
|
||||||
isCustomColumn,
|
isCustomColumn,
|
||||||
|
moveColumn,
|
||||||
|
moveColumnBy,
|
||||||
removeCustomColumn,
|
removeCustomColumn,
|
||||||
|
resetOrder,
|
||||||
resetWidth,
|
resetWidth,
|
||||||
resetWidths,
|
resetWidths,
|
||||||
resizeColumn,
|
resizeColumn,
|
||||||
@@ -31,6 +52,13 @@ import {
|
|||||||
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
|
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
|
||||||
import { addColumnDialogAtom } from "./AddColumnDialog";
|
import { addColumnDialogAtom } from "./AddColumnDialog";
|
||||||
|
|
||||||
|
/** Pixels a press on a label moves before it drags the column rather than sorting by it */
|
||||||
|
const DRAG_DISTANCE = 4;
|
||||||
|
/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */
|
||||||
|
const EDGE = 40;
|
||||||
|
/** The most the list scrolls sideways a frame, at the very edge */
|
||||||
|
const EDGE_SPEED = 18;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
columns: Column[];
|
columns: Column[];
|
||||||
template: string;
|
template: string;
|
||||||
@@ -43,13 +71,29 @@ interface Props {
|
|||||||
/** Sets the sort from the menus; empty goes back to arrival order */
|
/** Sets the sort from the menus; empty goes back to arrival order */
|
||||||
onSetSort: (sort: FlowSortKey[]) => void;
|
onSetSort: (sort: FlowSortKey[]) => void;
|
||||||
onResetSort: () => void;
|
onResetSort: () => void;
|
||||||
|
/** The rows' scrolling element, which a column dragged to its edge scrolls sideways */
|
||||||
|
scrollElement: () => HTMLElement | null;
|
||||||
|
/** A column's drag, for the list to show: its ghost, and the columns making room for it */
|
||||||
|
onColumnDrag: (event: ColumnDragEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it
|
||||||
|
* would drop at when that changes (`to`, null where it would stay), and its end, dropped there
|
||||||
|
* or put back
|
||||||
|
*/
|
||||||
|
export type ColumnDragEvent =
|
||||||
|
| { type: "start"; id: ColumnId; x: number }
|
||||||
|
| { type: "move"; x: number }
|
||||||
|
| { type: "target"; to: number | null }
|
||||||
|
| { type: "end"; dropped: boolean };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The list's only control surface: a click on a label sorts by it alone, a second reverses it
|
* The list's only control surface: a click on a label sorts by it alone, a second reverses it
|
||||||
* and a third goes back to arrival order; Option-click adds it to the sort as a further key.
|
* and a third goes back to arrival order; Option-click adds it to the sort as a further key.
|
||||||
* Dragging the edge after a label resizes its column and a double-click on it resets it. A
|
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
|
||||||
* right-click on a label opens the column's menu; the picker sits in the empty corner on hover.
|
* resizes its column and a double-click on it resets it. A right-click on a label opens the
|
||||||
|
* column's menu; the picker sits in the empty corner on hover.
|
||||||
*/
|
*/
|
||||||
export const FlowListHeader = memo(function FlowListHeader({
|
export const FlowListHeader = memo(function FlowListHeader({
|
||||||
columns,
|
columns,
|
||||||
@@ -60,6 +104,8 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
onSort,
|
onSort,
|
||||||
onSetSort: setSort,
|
onSetSort: setSort,
|
||||||
onResetSort,
|
onResetSort,
|
||||||
|
scrollElement,
|
||||||
|
onColumnDrag,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const setPrefs = useSetAtom(columnPrefsAtom);
|
const setPrefs = useSetAtom(columnPrefsAtom);
|
||||||
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
|
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
|
||||||
@@ -67,6 +113,12 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
|
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
|
||||||
// Widths when a drag began, so each move sets the width from where it started.
|
// Widths when a drag began, so each move sets the width from where it started.
|
||||||
const startWidth = useRef(0);
|
const startWidth = useRef(0);
|
||||||
|
const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
|
||||||
|
columns,
|
||||||
|
scrollElement,
|
||||||
|
onColumnDrag,
|
||||||
|
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
|
||||||
|
});
|
||||||
|
|
||||||
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
|
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
|
||||||
const removeColumn = (id: CustomColumnId) => {
|
const removeColumn = (id: CustomColumnId) => {
|
||||||
@@ -110,11 +162,17 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
||||||
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
|
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
|
||||||
|
{
|
||||||
|
label: "Reset Column Order",
|
||||||
|
disabled: prefs.order.length === 0,
|
||||||
|
onSelect: () => setPrefs((p) => resetOrder(p)),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const columnItems = (c: ColumnDef): DropdownItem[] => {
|
const columnItems = (c: ColumnDef): DropdownItem[] => {
|
||||||
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
|
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
|
||||||
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
|
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
|
||||||
|
const at = columns.findIndex((col) => col.id === c.id);
|
||||||
return [
|
return [
|
||||||
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
|
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
|
||||||
{ label: "Sort Descending", onSelect: () => sortBy(true) },
|
{ label: "Sort Descending", onSelect: () => sortBy(true) },
|
||||||
@@ -122,6 +180,17 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
|
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
|
||||||
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
|
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "Move Left",
|
||||||
|
disabled: at <= 0,
|
||||||
|
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Move Right",
|
||||||
|
disabled: at === -1 || at >= columns.length - 1,
|
||||||
|
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)),
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "Hide Column",
|
label: "Hide Column",
|
||||||
disabled: !canHide(prefs, c.id, unavailable),
|
disabled: !canHide(prefs, c.id, unavailable),
|
||||||
@@ -163,6 +232,9 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
<div style={{ width, minWidth: "100%" }} className="px-1.5">
|
<div style={{ width, minWidth: "100%" }} className="px-1.5">
|
||||||
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
|
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
|
||||||
<div
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
data-flow-header
|
||||||
|
onPointerDownCapture={onPointerDownCapture}
|
||||||
onContextMenu={openMenu}
|
onContextMenu={openMenu}
|
||||||
style={{ gridTemplateColumns: template }}
|
style={{ gridTemplateColumns: template }}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -182,60 +254,21 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</span>
|
</span>
|
||||||
{columns.map((c) => {
|
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
|
||||||
const at = sort.findIndex((k) => k.column === c.id);
|
{columns.map((c) => (
|
||||||
const key = sort[at];
|
<HeaderCell
|
||||||
const label = (
|
|
||||||
<>
|
|
||||||
{key != null && (
|
|
||||||
<Icon
|
|
||||||
icon="chevron_down"
|
|
||||||
size="xs"
|
|
||||||
className={classNames("shrink-0", !key.descending && "rotate-180")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{key != null && sort.length > 1 && (
|
|
||||||
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
|
|
||||||
)}
|
|
||||||
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const labelClass = classNames(
|
|
||||||
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
|
|
||||||
c.align === "right" && "justify-end",
|
|
||||||
key != null && "text-text-subtle",
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={c.id}
|
key={c.id}
|
||||||
data-column={c.id}
|
column={c}
|
||||||
className="relative h-full flex items-center min-w-0 px-1.5"
|
sort={sort}
|
||||||
>
|
onSort={onSort}
|
||||||
<button
|
onResizeStart={() => {
|
||||||
type="button"
|
startWidth.current = c.current;
|
||||||
onClick={(e) => onSort(c.id, e.altKey)}
|
}}
|
||||||
// No tooltip: the header sits under the search field, and a tooltip here covered it
|
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
|
||||||
aria-label={`Sort by ${c.label}`}
|
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
|
||||||
className={classNames(labelClass, "hover:text-text-subtle")}
|
/>
|
||||||
>
|
))}
|
||||||
{label}
|
</DndContext>
|
||||||
</button>
|
|
||||||
{/* On the column's right edge, the boundary with the next, resizing this one */}
|
|
||||||
<ResizeHandle
|
|
||||||
side="right"
|
|
||||||
justify="center"
|
|
||||||
className="absolute top-0 translate-x-1/2"
|
|
||||||
onResizeStart={() => {
|
|
||||||
startWidth.current = c.current;
|
|
||||||
}}
|
|
||||||
onResizeMove={(e) =>
|
|
||||||
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart))
|
|
||||||
}
|
|
||||||
onReset={() => setPrefs((p) => resetWidth(p, c.id))}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
triggerPosition={menu}
|
triggerPosition={menu}
|
||||||
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
|
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
|
||||||
@@ -245,3 +278,264 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface HeaderCellProps {
|
||||||
|
column: Column;
|
||||||
|
sort: FlowSortKey[];
|
||||||
|
onSort: (column: ColumnId, add: boolean) => void;
|
||||||
|
onResizeStart: () => void;
|
||||||
|
/** How far the edge moved since the resize began */
|
||||||
|
onResizeMove: (dx: number) => void;
|
||||||
|
onResetWidth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderCell({
|
||||||
|
column: c,
|
||||||
|
sort,
|
||||||
|
onSort,
|
||||||
|
onResizeStart,
|
||||||
|
onResizeMove,
|
||||||
|
onResetWidth,
|
||||||
|
}: HeaderCellProps) {
|
||||||
|
// The label drags the column; the resize handle beside it is outside it, so it still resizes.
|
||||||
|
const { listeners, setNodeRef } = useDraggable({ id: c.id });
|
||||||
|
const sorted = sort.some((k) => k.column === c.id);
|
||||||
|
return (
|
||||||
|
<span data-column={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
|
||||||
|
<button
|
||||||
|
ref={setNodeRef}
|
||||||
|
type="button"
|
||||||
|
{...listeners}
|
||||||
|
// A press that moves drags instead; dnd-kit swallows the click that ends a drag.
|
||||||
|
onClick={(e) => onSort(c.id, e.altKey)}
|
||||||
|
// No tooltip: the header sits under the search field, and a tooltip here covered it
|
||||||
|
aria-label={`Sort by ${c.label}`}
|
||||||
|
className={classNames(
|
||||||
|
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
|
||||||
|
"hover:text-text-subtle",
|
||||||
|
c.align === "right" && "justify-end",
|
||||||
|
sorted && "text-text-subtle",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ColumnLabel column={c} sort={sort} />
|
||||||
|
</button>
|
||||||
|
{/* On the column's right edge, the boundary with the next, resizing this one */}
|
||||||
|
<ResizeHandle
|
||||||
|
side="right"
|
||||||
|
justify="center"
|
||||||
|
className="absolute top-0 translate-x-1/2"
|
||||||
|
onResizeStart={onResizeStart}
|
||||||
|
onResizeMove={(e) => onResizeMove(e.x - e.xStart)}
|
||||||
|
onReset={onResetWidth}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A column's name, after its sort direction and, sorted by several, its place in the sort */
|
||||||
|
function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[] }) {
|
||||||
|
const at = sort.findIndex((k) => k.column === c.id);
|
||||||
|
const key = sort[at];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{key != null && (
|
||||||
|
<Icon
|
||||||
|
icon="chevron_down"
|
||||||
|
size="xs"
|
||||||
|
className={classNames("shrink-0", !key.descending && "rotate-180")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{key != null && sort.length > 1 && (
|
||||||
|
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging a column's label: where it would drop, the boundary between the labels nearest the
|
||||||
|
* pointer as they were laid out when the drag began, and the list scrolling sideways while the
|
||||||
|
* pointer is near its edge. The list shows the drag; the columns only change order on the drop,
|
||||||
|
* so the rows lay out once.
|
||||||
|
*/
|
||||||
|
function useColumnDrag({
|
||||||
|
columns,
|
||||||
|
scrollElement,
|
||||||
|
onColumnDrag,
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
columns: Column[];
|
||||||
|
scrollElement: () => HTMLElement | null;
|
||||||
|
onColumnDrag: (event: ColumnDragEvent) => void;
|
||||||
|
/** Moves the column to the boundary `to` between those shown */
|
||||||
|
onDrop: (id: ColumnId, to: number) => void;
|
||||||
|
}) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }),
|
||||||
|
);
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Live while dragging, read each frame
|
||||||
|
const state = useRef<{
|
||||||
|
/** The column dragged, and its place among those shown */
|
||||||
|
id: ColumnId;
|
||||||
|
from: number;
|
||||||
|
/** The labels' edges when the drag began, from the header's left, as the columns slide */
|
||||||
|
cells: { left: number; right: number }[];
|
||||||
|
/** The columns shown when it began, which the edges and `from` describe */
|
||||||
|
shown: string;
|
||||||
|
/** The pointer, across the window */
|
||||||
|
x: number;
|
||||||
|
/** The boundary it would move to, null where it would stay */
|
||||||
|
to: number | null;
|
||||||
|
/** The pointer the ghost was last moved to */
|
||||||
|
ghostX: number;
|
||||||
|
frame: number;
|
||||||
|
} | null>(null);
|
||||||
|
const latest = useRef({ columns, scrollElement, onColumnDrag, onDrop });
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
latest.current = { columns, scrollElement, onColumnDrag, onDrop };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ends the drag, dropped or put back */
|
||||||
|
const stop = useCallback((dropped: boolean) => {
|
||||||
|
const s = state.current;
|
||||||
|
state.current = null;
|
||||||
|
if (s == null) return null;
|
||||||
|
cancelAnimationFrame(s.frame);
|
||||||
|
latest.current.onColumnDrag({ type: "end", dropped });
|
||||||
|
return s;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDragStart = useCallback((e: DragStartEvent) => {
|
||||||
|
const grid = gridRef.current;
|
||||||
|
const from = latest.current.columns.findIndex((c) => c.id === e.active.id);
|
||||||
|
if (grid == null || from === -1) return;
|
||||||
|
const id = e.active.id as ColumnId;
|
||||||
|
const x = pointer.current;
|
||||||
|
// First, as it ends the last drag's columns sliding home, so the edges read below are where
|
||||||
|
// the columns are laid out, not where they were sliding
|
||||||
|
latest.current.onColumnDrag({ type: "start", id, x });
|
||||||
|
const gridLeft = grid.getBoundingClientRect().left;
|
||||||
|
const cells = Array.from(grid.querySelectorAll<HTMLElement>(":scope > [data-column]")).map(
|
||||||
|
(el) => {
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return { left: box.left - gridLeft, right: box.right - gridLeft };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const shown = shownKey(latest.current.columns);
|
||||||
|
state.current = { id, from, cells, shown, x, to: null, ghostX: x, frame: 0 };
|
||||||
|
target(state.current);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const s = state.current;
|
||||||
|
if (s == null) return;
|
||||||
|
// Near an edge, the list scrolls that way, faster the nearer; the header follows it.
|
||||||
|
const list = latest.current.scrollElement();
|
||||||
|
if (list != null) {
|
||||||
|
const { left, right } = list.getBoundingClientRect();
|
||||||
|
const near =
|
||||||
|
s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
|
||||||
|
if (near !== 0) {
|
||||||
|
const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
|
||||||
|
list.scrollLeft += Math.sign(near) * Math.max(1, speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target(s);
|
||||||
|
if (s.x !== s.ghostX) {
|
||||||
|
s.ghostX = s.x;
|
||||||
|
latest.current.onColumnDrag({ type: "move", x: s.x });
|
||||||
|
}
|
||||||
|
s.frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
state.current.frame = requestAnimationFrame(tick);
|
||||||
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Drops the column where the pointer is, once; a second call finds no drag */
|
||||||
|
const drop = useCallback(() => {
|
||||||
|
// Where the pointer is now, as a release can come before the next frame reads it
|
||||||
|
if (state.current != null) target(state.current);
|
||||||
|
// The list lets go of the drag and the order changes in the same task, so nothing jumps.
|
||||||
|
const s = stop(true);
|
||||||
|
if (s?.to != null) latest.current.onDrop(s.id, s.to);
|
||||||
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
|
||||||
|
}, [stop]);
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
stop(false);
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
// Columns shown or hidden under a drag, as App is when the first flow with an app arrives,
|
||||||
|
// leave its edges and target describing columns that are gone; it is put back instead.
|
||||||
|
const shown = shownKey(columns);
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.current != null && state.current.shown !== shown) cancel();
|
||||||
|
}, [shown, cancel]);
|
||||||
|
|
||||||
|
// The pointer from a press on the header until its release, read before dnd-kit sees it.
|
||||||
|
// dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the
|
||||||
|
// release only once it has rendered the drag, so a release right after a move would drop
|
||||||
|
// where the pointer was a frame before, or not at all. The release drops it here instead.
|
||||||
|
const pointer = useRef(0);
|
||||||
|
const track = useCallback((e: PointerEvent) => {
|
||||||
|
pointer.current = e.clientX;
|
||||||
|
if (state.current != null) state.current.x = e.clientX;
|
||||||
|
}, []);
|
||||||
|
const unlisten = useRef<(() => void) | null>(null);
|
||||||
|
const onPointerDownCapture = useCallback(
|
||||||
|
(e: ReactPointerEvent) => {
|
||||||
|
unlisten.current?.();
|
||||||
|
pointer.current = e.clientX;
|
||||||
|
const up = (e: PointerEvent) => {
|
||||||
|
unlisten.current?.();
|
||||||
|
track(e);
|
||||||
|
drop();
|
||||||
|
};
|
||||||
|
const lost = () => {
|
||||||
|
unlisten.current?.();
|
||||||
|
cancel();
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", track, true);
|
||||||
|
window.addEventListener("pointerup", up, true);
|
||||||
|
window.addEventListener("pointercancel", lost, true);
|
||||||
|
unlisten.current = () => {
|
||||||
|
unlisten.current = null;
|
||||||
|
window.removeEventListener("pointermove", track, true);
|
||||||
|
window.removeEventListener("pointerup", up, true);
|
||||||
|
window.removeEventListener("pointercancel", lost, true);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[track, drop, cancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
// A drag never outlives the header
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
unlisten.current?.();
|
||||||
|
cancel();
|
||||||
|
},
|
||||||
|
[cancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Sets where the drag would drop, telling the list when that changes */
|
||||||
|
function target(s: NonNullable<typeof state.current>) {
|
||||||
|
const gridLeft = gridRef.current?.getBoundingClientRect().left ?? 0;
|
||||||
|
const cells = s.cells.map((c) => ({ left: gridLeft + c.left, right: gridLeft + c.right }));
|
||||||
|
const to = dropBoundary(s.x, cells, s.from);
|
||||||
|
if (to === s.to) return;
|
||||||
|
s.to = to;
|
||||||
|
latest.current.onColumnDrag({ type: "target", to });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sensors,
|
||||||
|
gridRef,
|
||||||
|
onPointerDownCapture,
|
||||||
|
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: cancel },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which columns show, in order, to tell when that changes */
|
||||||
|
function shownKey(columns: Column[]): string {
|
||||||
|
return columns.map((c) => c.id).join(" ");
|
||||||
|
}
|
||||||
@@ -21,17 +21,9 @@ const data = (truncated = false): FlowBodyData => ({
|
|||||||
storedSize: 7,
|
storedSize: 7,
|
||||||
});
|
});
|
||||||
|
|
||||||
function render(
|
function render(body: Parameters<typeof FlowRequestBody>[0]["body"]) {
|
||||||
body: Parameters<typeof FlowRequestBody>[0]["body"],
|
|
||||||
binaryViewer: string | null = null,
|
|
||||||
) {
|
|
||||||
return renderToStaticMarkup(
|
return renderToStaticMarkup(
|
||||||
<FlowRequestBody
|
<FlowRequestBody body={body} viewer={<div>EDITOR</div>} requestSize={2048} />,
|
||||||
body={body}
|
|
||||||
editor={<div>EDITOR</div>}
|
|
||||||
binaryViewer={binaryViewer == null ? null : <div>{binaryViewer}</div>}
|
|
||||||
requestSize={2048}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +41,8 @@ describe("FlowRequestBody", () => {
|
|||||||
expect(html).toContain("LOADING");
|
expect(html).toContain("LOADING");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the editor once loaded, the proxy's viewer for a binary body", () => {
|
test("the viewer once loaded", () => {
|
||||||
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
|
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
|
||||||
expect(render({ data: data(), isLoading: false, error: null }, "HEXDUMP")).toContain("HEXDUMP");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a cut body says how much was kept", () => {
|
test("a cut body says how much was kept", () => {
|
||||||
|
|||||||
@@ -13,19 +13,17 @@ import type { FlowBodyData } from "../lib/flowViews";
|
|||||||
interface Props {
|
interface Props {
|
||||||
/** The request body as the engine's body RPC returned it */
|
/** The request body as the engine's body RPC returned it */
|
||||||
body: HttpBodyResult<FlowBodyData | null>;
|
body: HttpBodyResult<FlowBodyData | null>;
|
||||||
/** The shared editor for the body's type */
|
/** The body in the viewer for its type, formatted or raw */
|
||||||
editor: ReactNode;
|
viewer: ReactNode;
|
||||||
/** The proxy's own viewer, for a body that isn't text; null for one that is */
|
|
||||||
binaryViewer: ReactNode | null;
|
|
||||||
/** Bytes of the request body on the wire */
|
/** Bytes of the request body on the wire */
|
||||||
requestSize: number;
|
requestSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The request half's Body tab: the body in its editor, or why it isn't there yet. A body that
|
* The request half's Body tab: the body in its viewer, or why it isn't there yet. A body that
|
||||||
* failed to load says why, as the response's viewer does, rather than reading as no body.
|
* failed to load says why, as the response's viewer does, rather than reading as no body.
|
||||||
*/
|
*/
|
||||||
export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Props) {
|
export function FlowRequestBody({ body, viewer, requestSize }: Props) {
|
||||||
if (body.error != null) {
|
if (body.error != null) {
|
||||||
return (
|
return (
|
||||||
<Banner color="danger" className="text-sm">
|
<Banner color="danger" className="text-sm">
|
||||||
@@ -40,7 +38,6 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
|
|||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (binaryViewer != null) return binaryViewer;
|
|
||||||
if (body.data?.truncated) {
|
if (body.data?.truncated) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
@@ -51,9 +48,9 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Banner>
|
</Banner>
|
||||||
<div className="min-h-0">{editor}</div>
|
<div className="min-h-0">{viewer}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return editor;
|
return viewer;
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,38 @@ const graphqlRequest = JSON.stringify({
|
|||||||
operationName: "GetViewer",
|
operationName: "GetViewer",
|
||||||
});
|
});
|
||||||
const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write";
|
const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write";
|
||||||
|
const csvResponse = [
|
||||||
|
"id,email,plan,created_at",
|
||||||
|
"1,ada@example.com,pro,2026-10-01",
|
||||||
|
"2,grace@example.com,team,2026-10-14",
|
||||||
|
"3,linus@example.com,free,2026-11-02",
|
||||||
|
].join("\n");
|
||||||
|
const tsvResponse = [
|
||||||
|
"sku\tname\tstock\tnote",
|
||||||
|
"A-100\tDesk lamp\t42\tWarm white, dimmable, ships with a long braided fabric cable",
|
||||||
|
"A-101\tMonitor arm\t7\t",
|
||||||
|
"A-102\tKeyboard tray\t0\tBack in stock next week",
|
||||||
|
].join("\n");
|
||||||
|
const multipartBoundary = "yaakBoundary7MA4YWxk";
|
||||||
|
const multipartRequest = [
|
||||||
|
`--${multipartBoundary}`,
|
||||||
|
'Content-Disposition: form-data; name="title"',
|
||||||
|
"",
|
||||||
|
"Quarterly report",
|
||||||
|
`--${multipartBoundary}`,
|
||||||
|
'Content-Disposition: form-data; name="meta"; filename="meta.json"',
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"",
|
||||||
|
'{"pages":12,"draft":false}',
|
||||||
|
`--${multipartBoundary}`,
|
||||||
|
'Content-Disposition: form-data; name="rows"; filename="rows.csv"',
|
||||||
|
"Content-Type: text/csv",
|
||||||
|
"",
|
||||||
|
"region,revenue\nnorth,1200\nsouth,980",
|
||||||
|
`--${multipartBoundary}--`,
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
|
const textResponse = "Uploaded 3 parts";
|
||||||
const htmlResponse =
|
const htmlResponse =
|
||||||
"<!doctype html><html><body style='font-family:sans-serif'><h1>Signed in</h1><p>You can close this window.</p></body></html>";
|
"<!doctype html><html><body style='font-family:sans-serif'><h1>Signed in</h1><p>You can close this window.</p></body></html>";
|
||||||
// A 24x24 PNG gradient
|
// A 24x24 PNG gradient
|
||||||
@@ -198,6 +230,55 @@ export const fixtures: FlowFixture[] = [
|
|||||||
requestBody: body(graphqlRequest),
|
requestBody: body(graphqlRequest),
|
||||||
responseBody: body('{"data":{"viewer":{"login":"yaak"}}}'),
|
responseBody: body('{"data":{"viewer":{"login":"yaak"}}}'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "CSV GET",
|
||||||
|
flow: {
|
||||||
|
...base,
|
||||||
|
id: "fl_csv",
|
||||||
|
url: "https://api.example.com/users/export.csv",
|
||||||
|
contentType: "text/csv",
|
||||||
|
requestHeaders: [{ name: "Accept", value: "text/csv" }],
|
||||||
|
responseHeaders: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }],
|
||||||
|
responseSize: csvResponse.length,
|
||||||
|
},
|
||||||
|
requestBody: body(""),
|
||||||
|
responseBody: body(csvResponse),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "TSV GET",
|
||||||
|
flow: {
|
||||||
|
...base,
|
||||||
|
id: "fl_tsv",
|
||||||
|
url: "https://api.example.com/inventory.tsv",
|
||||||
|
contentType: "text/tab-separated-values",
|
||||||
|
requestHeaders: [{ name: "Accept", value: "text/tab-separated-values" }],
|
||||||
|
responseHeaders: [{ name: "Content-Type", value: "text/tab-separated-values" }],
|
||||||
|
responseSize: tsvResponse.length,
|
||||||
|
},
|
||||||
|
requestBody: body(""),
|
||||||
|
responseBody: body(tsvResponse),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Multipart POST",
|
||||||
|
flow: {
|
||||||
|
...base,
|
||||||
|
id: "fl_multipart",
|
||||||
|
method: "POST",
|
||||||
|
url: "https://api.example.com/reports",
|
||||||
|
contentType: "text/plain",
|
||||||
|
requestHeaders: [
|
||||||
|
{
|
||||||
|
name: "Content-Type",
|
||||||
|
value: `multipart/form-data; boundary=${multipartBoundary}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responseHeaders: [{ name: "Content-Type", value: "text/plain; charset=utf-8" }],
|
||||||
|
requestSize: multipartRequest.length,
|
||||||
|
responseSize: textResponse.length,
|
||||||
|
},
|
||||||
|
requestBody: body(multipartRequest),
|
||||||
|
responseBody: body(textResponse),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "gRPC call",
|
label: "gRPC call",
|
||||||
flow: {
|
flow: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||||
import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
|
import type { ResponseViewMode, SplitLayoutLayout } from "@yaakapp-internal/ui";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
import { currentPlace, EMPTY_PLACE, type PlaceStack, stackOf, updatePlace } from "./places";
|
import { currentPlace, EMPTY_PLACE, type PlaceStack, stackOf, updatePlace } from "./places";
|
||||||
@@ -63,3 +63,11 @@ export const helperDialogOpenAtom = atom(false);
|
|||||||
|
|
||||||
/** Stacked by default; the header's toggle switches to side by side */
|
/** Stacked by default; the header's toggle switches to side by side */
|
||||||
export const flowLayoutAtom = atomWithStorage<SplitLayoutLayout>("flow_layout", "vertical");
|
export const flowLayoutAtom = atomWithStorage<SplitLayoutLayout>("flow_layout", "vertical");
|
||||||
|
|
||||||
|
/** Bodies show formatted or raw, one choice for both halves of every flow */
|
||||||
|
export const bodyViewModeAtom = atomWithStorage<ResponseViewMode>(
|
||||||
|
"body_view_mode",
|
||||||
|
"pretty",
|
||||||
|
undefined,
|
||||||
|
{ getOnInit: true },
|
||||||
|
);
|
||||||
@@ -4,21 +4,27 @@ import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtur
|
|||||||
import {
|
import {
|
||||||
addCustomColumn,
|
addCustomColumn,
|
||||||
addSortKey,
|
addSortKey,
|
||||||
|
allColumns,
|
||||||
cellFilterTerm,
|
cellFilterTerm,
|
||||||
|
columnShifts,
|
||||||
type ColumnPrefs,
|
type ColumnPrefs,
|
||||||
customPath,
|
customPath,
|
||||||
customPathProblem,
|
customPathProblem,
|
||||||
customValue,
|
customValue,
|
||||||
defaultColumnPrefs,
|
defaultColumnPrefs,
|
||||||
|
dropBoundary,
|
||||||
fieldReference,
|
fieldReference,
|
||||||
fieldReferenceChanges,
|
fieldReferenceChanges,
|
||||||
gridTemplate,
|
gridTemplate,
|
||||||
gridWidth,
|
gridWidth,
|
||||||
hideOtherColumns,
|
hideOtherColumns,
|
||||||
|
moveColumn,
|
||||||
|
moveColumnBy,
|
||||||
nextSort,
|
nextSort,
|
||||||
operationOf,
|
operationOf,
|
||||||
parseColumnPrefs,
|
parseColumnPrefs,
|
||||||
removeCustomColumn,
|
removeCustomColumn,
|
||||||
|
resetOrder,
|
||||||
resetWidth,
|
resetWidth,
|
||||||
resetWidths,
|
resetWidths,
|
||||||
resizeColumn,
|
resizeColumn,
|
||||||
@@ -121,6 +127,7 @@ describe("column preferences", () => {
|
|||||||
visible: { number: false, "field:header.x-cache": true },
|
visible: { number: false, "field:header.x-cache": true },
|
||||||
widths: { host: 60 },
|
widths: { host: 60 },
|
||||||
custom: ["field:header.x-cache"],
|
custom: ["field:header.x-cache"],
|
||||||
|
order: [],
|
||||||
sort: [{ column: "status", descending: true }],
|
sort: [{ column: "status", descending: true }],
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -138,6 +145,7 @@ describe("column preferences", () => {
|
|||||||
visible: {},
|
visible: {},
|
||||||
widths: { size: 90 },
|
widths: { size: 90 },
|
||||||
custom: ["field:query.page"],
|
custom: ["field:query.page"],
|
||||||
|
order: [],
|
||||||
sort: [{ column: "time", descending: false }],
|
sort: [{ column: "time", descending: false }],
|
||||||
});
|
});
|
||||||
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
|
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
|
||||||
@@ -181,6 +189,196 @@ describe("column preferences", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("column order", () => {
|
||||||
|
const defaults = ids(defaultColumnPrefs);
|
||||||
|
|
||||||
|
test("a column moves to the boundary it was dropped at", () => {
|
||||||
|
expect(ids(moveColumn(defaultColumnPrefs, "status", 2))).toEqual([
|
||||||
|
"number",
|
||||||
|
"app",
|
||||||
|
"status",
|
||||||
|
"method",
|
||||||
|
"host",
|
||||||
|
"path",
|
||||||
|
"query",
|
||||||
|
"type",
|
||||||
|
"size",
|
||||||
|
"time",
|
||||||
|
]);
|
||||||
|
// Rightwards, before the column at the boundary
|
||||||
|
expect(ids(moveColumn(defaultColumnPrefs, "method", 5)).slice(2, 6)).toEqual([
|
||||||
|
"host",
|
||||||
|
"path",
|
||||||
|
"method",
|
||||||
|
"query",
|
||||||
|
]);
|
||||||
|
// To either end
|
||||||
|
expect(ids(moveColumn(defaultColumnPrefs, "time", 0))[0]).toBe("time");
|
||||||
|
expect(ids(moveColumn(defaultColumnPrefs, "number", defaults.length)).at(-1)).toBe("number");
|
||||||
|
// The picker lists them in the same order
|
||||||
|
const moved = moveColumn(defaultColumnPrefs, "time", 0);
|
||||||
|
expect(allColumns(moved)[0]?.id).toBe("time");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a drop where the column already is changes nothing", () => {
|
||||||
|
expect(moveColumn(defaultColumnPrefs, "host", 3)).toBe(defaultColumnPrefs);
|
||||||
|
expect(moveColumn(defaultColumnPrefs, "host", 4)).toBe(defaultColumnPrefs);
|
||||||
|
expect(moveColumn(defaultColumnPrefs, "host", -1)).toBe(defaultColumnPrefs);
|
||||||
|
expect(moveColumn(defaultColumnPrefs, "host", defaults.length + 1)).toBe(defaultColumnPrefs);
|
||||||
|
// A hidden column has no place among those shown
|
||||||
|
expect(moveColumn(defaultColumnPrefs, "latency", 0)).toBe(defaultColumnPrefs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Move Left and Move Right go one place, and not past the ends", () => {
|
||||||
|
expect(ids(moveColumnBy(defaultColumnPrefs, "host", -1)).slice(2, 5)).toEqual([
|
||||||
|
"host",
|
||||||
|
"method",
|
||||||
|
"path",
|
||||||
|
]);
|
||||||
|
expect(ids(moveColumnBy(defaultColumnPrefs, "host", 1)).slice(3, 6)).toEqual([
|
||||||
|
"path",
|
||||||
|
"host",
|
||||||
|
"query",
|
||||||
|
]);
|
||||||
|
expect(moveColumnBy(defaultColumnPrefs, "number", -1)).toBe(defaultColumnPrefs);
|
||||||
|
expect(moveColumnBy(defaultColumnPrefs, "time", 1)).toBe(defaultColumnPrefs);
|
||||||
|
// Where App has nothing to show, Number is next to Method
|
||||||
|
expect(
|
||||||
|
shownColumns(moveColumnBy(defaultColumnPrefs, "method", -1, ["app"]), ["app"]).map(
|
||||||
|
(c) => c.id,
|
||||||
|
)[0],
|
||||||
|
).toBe("method");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a hidden column keeps its place and comes back to it", () => {
|
||||||
|
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
|
||||||
|
prefs = toggleColumn(prefs, "status");
|
||||||
|
prefs = moveColumn(prefs, "host", 0);
|
||||||
|
expect(ids(prefs)).not.toContain("status");
|
||||||
|
prefs = toggleColumn(prefs, "status");
|
||||||
|
expect(ids(prefs)).toEqual([
|
||||||
|
"host",
|
||||||
|
"time",
|
||||||
|
"number",
|
||||||
|
"app",
|
||||||
|
"method",
|
||||||
|
"path",
|
||||||
|
"query",
|
||||||
|
"status",
|
||||||
|
"type",
|
||||||
|
"size",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never reordered, a built-in column turned on takes its default place", () => {
|
||||||
|
// Request Size's default place is before Size
|
||||||
|
const prefs = toggleColumn(defaultColumnPrefs, "requestSize");
|
||||||
|
expect(prefs.order).toEqual([]);
|
||||||
|
expect(ids(prefs).slice(-4)).toEqual(["type", "requestSize", "size", "time"]);
|
||||||
|
// Among the built-in ones, so a custom column stays at the end
|
||||||
|
const custom = addCustomColumn(defaultColumnPrefs, "header.x-cache");
|
||||||
|
expect(ids(custom).at(-1)).toBe("field:header.x-cache");
|
||||||
|
const both = toggleColumn(custom, "requestSize");
|
||||||
|
expect(both.order).toEqual([]);
|
||||||
|
expect(ids(both).slice(-4)).toEqual(["requestSize", "size", "time", "field:header.x-cache"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("once reordered, a column turned on goes at the end, unless it had a place", () => {
|
||||||
|
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
|
||||||
|
prefs = toggleColumn(prefs, "requestSize");
|
||||||
|
expect(ids(prefs).at(-1)).toBe("requestSize");
|
||||||
|
// Moved, hidden and shown again, it goes back where it was moved to
|
||||||
|
const before = ids(prefs).indexOf("size");
|
||||||
|
prefs = moveColumn(prefs, "requestSize", before);
|
||||||
|
prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize");
|
||||||
|
expect(ids(prefs).slice(-3)).toEqual(["type", "requestSize", "size"]);
|
||||||
|
// A custom column added goes at the end too
|
||||||
|
expect(ids(addCustomColumn(prefs, "query.page")).at(-1)).toBe("field:query.page");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a custom column is added at the end and leaves the order when removed", () => {
|
||||||
|
const moved = moveColumn(defaultColumnPrefs, "size", 0);
|
||||||
|
let prefs = addCustomColumn(moved, "header.x-cache");
|
||||||
|
expect(ids(prefs).at(-1)).toBe("field:header.x-cache");
|
||||||
|
prefs = moveColumn(prefs, "field:header.x-cache", 1);
|
||||||
|
expect(ids(prefs).slice(0, 2)).toEqual(["size", "field:header.x-cache"]);
|
||||||
|
// Hidden and added again, it keeps its place
|
||||||
|
prefs = addCustomColumn(toggleColumn(prefs, "field:header.x-cache"), "header.x-cache");
|
||||||
|
expect(ids(prefs)[1]).toBe("field:header.x-cache");
|
||||||
|
prefs = removeCustomColumn(prefs, "field:header.x-cache");
|
||||||
|
expect(prefs.order).not.toContain("field:header.x-cache");
|
||||||
|
expect(ids(prefs)).toEqual(ids(moved));
|
||||||
|
// Added again, it starts over at the end
|
||||||
|
expect(ids(addCustomColumn(prefs, "header.x-cache")).at(-1)).toBe("field:header.x-cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the order persists, and columns it doesn't list follow it in their default order", () => {
|
||||||
|
let prefs = addCustomColumn(defaultColumnPrefs, "query.page");
|
||||||
|
prefs = moveColumn(prefs, "field:query.page", 0);
|
||||||
|
expect(parseColumnPrefs(JSON.parse(JSON.stringify(prefs))).order).toEqual(prefs.order);
|
||||||
|
const parsed = parseColumnPrefs({
|
||||||
|
custom: ["field:query.page"],
|
||||||
|
order: ["time", "gone", 3, "time", "field:header.x-gone", "field:query.page", "host"],
|
||||||
|
});
|
||||||
|
expect(parsed.order).toEqual(["time", "field:query.page", "host"]);
|
||||||
|
expect(ids(parsed)).toEqual([
|
||||||
|
"time",
|
||||||
|
"field:query.page",
|
||||||
|
"host",
|
||||||
|
"number",
|
||||||
|
"app",
|
||||||
|
"method",
|
||||||
|
"path",
|
||||||
|
"query",
|
||||||
|
"status",
|
||||||
|
"type",
|
||||||
|
"size",
|
||||||
|
]);
|
||||||
|
expect(parseColumnPrefs({ order: "time" }).order).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a drag's target is the boundary nearest the pointer, null in its own slot", () => {
|
||||||
|
// Four columns 100px wide from x 24, after the mark column; the third is dragged
|
||||||
|
const cells = [24, 124, 224, 324].map((left) => ({ left, right: left + 100 }));
|
||||||
|
const at = (x: number) => dropBoundary(x, cells, 2);
|
||||||
|
// Over the mark column or the first's left half: the first boundary
|
||||||
|
expect(at(5)).toBe(0);
|
||||||
|
expect(at(60)).toBe(0);
|
||||||
|
expect(at(80)).toBe(1);
|
||||||
|
// The left neighbour's near half, its own column, the right neighbour's near half: staying
|
||||||
|
expect(at(190)).toBeNull();
|
||||||
|
expect(at(250)).toBeNull();
|
||||||
|
expect(at(290)).toBeNull();
|
||||||
|
expect(at(350)).toBeNull();
|
||||||
|
// Past the right neighbour's middle, and past the end
|
||||||
|
expect(at(380)).toBe(4);
|
||||||
|
expect(at(900)).toBe(4);
|
||||||
|
// The last column dragged to the end stays
|
||||||
|
expect(dropBoundary(900, cells, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the columns a drag passes slide by its width to make room", () => {
|
||||||
|
// Five columns; the second, 80px wide, is dragged
|
||||||
|
expect(columnShifts(5, 1, 80, null)).toEqual([0, 0, 0, 0, 0]);
|
||||||
|
// Right, to before the fifth: the third and fourth slide left
|
||||||
|
expect(columnShifts(5, 1, 80, 4)).toEqual([0, 0, -80, -80, 0]);
|
||||||
|
// Right, to the end: all after it slide left
|
||||||
|
expect(columnShifts(5, 1, 80, 5)).toEqual([0, 0, -80, -80, -80]);
|
||||||
|
// Left, to the start: the first slides right
|
||||||
|
expect(columnShifts(5, 1, 80, 0)).toEqual([80, 0, 0, 0, 0]);
|
||||||
|
// The fourth, dragged left to before the second: the second and third slide right
|
||||||
|
expect(columnShifts(5, 3, 60, 1)).toEqual([0, 60, 60, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Reset Column Order puts every column back", () => {
|
||||||
|
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
|
||||||
|
prefs = toggleColumn(prefs, "latency");
|
||||||
|
prefs = resetOrder(prefs);
|
||||||
|
expect(prefs.order).toEqual([]);
|
||||||
|
expect(ids(prefs)).toEqual([...defaults.slice(0, -1), "time", "latency"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sorting", () => {
|
describe("sorting", () => {
|
||||||
const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }];
|
const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }];
|
||||||
|
|
||||||
|
|||||||
+140
-11
@@ -145,26 +145,45 @@ export const CELL_PADDING = "0.75rem";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* What the person chose: which columns show, the widths they dragged, by id, the header and
|
* What the person chose: which columns show, the widths they dragged, by id, the header and
|
||||||
* query parameter columns they added, in order, and the list's sort
|
* query parameter columns they added, in order, the order they dragged the columns into, and the
|
||||||
|
* list's sort
|
||||||
*/
|
*/
|
||||||
export interface ColumnPrefs {
|
export interface ColumnPrefs {
|
||||||
visible: Partial<Record<ColumnId, boolean>>;
|
visible: Partial<Record<ColumnId, boolean>>;
|
||||||
widths: Partial<Record<ColumnId, number>>;
|
widths: Partial<Record<ColumnId, number>>;
|
||||||
custom: CustomColumnId[];
|
custom: CustomColumnId[];
|
||||||
|
/**
|
||||||
|
* The columns' order, by id. Columns not listed follow the listed ones, in their default order;
|
||||||
|
* empty for the default order.
|
||||||
|
*/
|
||||||
|
order: ColumnId[];
|
||||||
/** The keys in order; empty for the order flows arrive in */
|
/** The keys in order; empty for the order flows arrive in */
|
||||||
sort: FlowSortKey[];
|
sort: FlowSortKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] };
|
export const defaultColumnPrefs: ColumnPrefs = {
|
||||||
|
visible: {},
|
||||||
|
widths: {},
|
||||||
|
custom: [],
|
||||||
|
order: [],
|
||||||
|
sort: [],
|
||||||
|
};
|
||||||
|
|
||||||
export interface Column extends ColumnDef {
|
export interface Column extends ColumnDef {
|
||||||
/** Its width now, in pixels of content */
|
/** Its width now, in pixels of content */
|
||||||
current: number;
|
current: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every column the picker lists: the built-in ones, then those the person added */
|
/**
|
||||||
|
* Every column, in the order the person left them: those they placed, then the rest in their
|
||||||
|
* default order, the built-in ones before those added. The picker lists them so.
|
||||||
|
*/
|
||||||
export function allColumns(prefs: ColumnPrefs): ColumnDef[] {
|
export function allColumns(prefs: ColumnPrefs): ColumnDef[] {
|
||||||
return [...COLUMNS, ...prefs.custom.map(customDef)];
|
const defaults = [...COLUMNS, ...prefs.custom.map(customDef)];
|
||||||
|
if (prefs.order.length === 0) return defaults;
|
||||||
|
const byId = new Map(defaults.map((c) => [c.id, c]));
|
||||||
|
const placed = prefs.order.flatMap((id) => byId.get(id) ?? []);
|
||||||
|
return [...placed, ...defaults.filter((c) => !prefs.order.includes(c.id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean {
|
function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean {
|
||||||
@@ -217,7 +236,104 @@ export function toggleColumn(
|
|||||||
): ColumnPrefs {
|
): ColumnPrefs {
|
||||||
const shown = isVisible(prefs, columnDef(id));
|
const shown = isVisible(prefs, columnDef(id));
|
||||||
if (shown && !canHide(prefs, id, unavailable)) return prefs;
|
if (shown && !canHide(prefs, id, unavailable)) return prefs;
|
||||||
return { ...prefs, visible: { ...prefs.visible, [id]: !shown } };
|
const next = { ...prefs, visible: { ...prefs.visible, [id]: !shown } };
|
||||||
|
return shown ? next : placeAtEnd(next, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a column just shown after the others shown, unless the person placed it before, so it
|
||||||
|
* goes back to that place. The columns shown before it are placed too, where they are, so the
|
||||||
|
* ones not listed don't jump ahead of it. Until the person has ordered the columns, each keeps
|
||||||
|
* its default place: a built-in one among the others, an added one at the end.
|
||||||
|
*/
|
||||||
|
function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
|
||||||
|
if (prefs.order.length === 0 || prefs.order.includes(id)) return prefs;
|
||||||
|
const all = allColumns(prefs);
|
||||||
|
if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs;
|
||||||
|
const placed = all.filter(
|
||||||
|
(c) => c.id !== id && (prefs.order.includes(c.id) || isVisible(prefs, c)),
|
||||||
|
);
|
||||||
|
return { ...prefs, order: [...placed.map((c) => c.id), id] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a shown column to the boundary `to` between the columns shown: 0 before the first, their
|
||||||
|
* count after the last. Hidden columns keep their places among the others.
|
||||||
|
*/
|
||||||
|
export function moveColumn(
|
||||||
|
prefs: ColumnPrefs,
|
||||||
|
id: ColumnId,
|
||||||
|
to: number,
|
||||||
|
unavailable: ColumnId[] = [],
|
||||||
|
): ColumnPrefs {
|
||||||
|
const shown = shownColumns(prefs, unavailable).map((c) => c.id);
|
||||||
|
const from = shown.indexOf(id);
|
||||||
|
if (from === -1 || to < 0 || to > shown.length || to === from || to === from + 1) return prefs;
|
||||||
|
// Every column shown, and those hidden that were placed, so they keep their places
|
||||||
|
const order = allColumns(prefs)
|
||||||
|
.filter((c) => c.id !== id && (prefs.order.includes(c.id) || shown.includes(c.id)))
|
||||||
|
.map((c) => c.id);
|
||||||
|
// Before the column at the boundary, or after the last one shown
|
||||||
|
const before = shown[to];
|
||||||
|
const last = shown.at(-1);
|
||||||
|
const at = before != null ? order.indexOf(before) : last == null ? -1 : order.indexOf(last) + 1;
|
||||||
|
if (at === -1) return prefs;
|
||||||
|
order.splice(at, 0, id);
|
||||||
|
return { ...prefs, order };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One place left, or with `1`, right, among the columns shown */
|
||||||
|
export function moveColumnBy(
|
||||||
|
prefs: ColumnPrefs,
|
||||||
|
id: ColumnId,
|
||||||
|
by: -1 | 1,
|
||||||
|
unavailable: ColumnId[] = [],
|
||||||
|
): ColumnPrefs {
|
||||||
|
const from = shownColumns(prefs, unavailable).findIndex((c) => c.id === id);
|
||||||
|
if (from === -1) return prefs;
|
||||||
|
return moveColumn(prefs, id, by === -1 ? from - 1 : from + 2, unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every column back to its default place */
|
||||||
|
export function resetOrder(prefs: ColumnPrefs): ColumnPrefs {
|
||||||
|
return { ...prefs, order: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a dragged column would drop, from the pointer and the shown columns' edges, left to
|
||||||
|
* right, as they are laid out before the drag: the boundary before the first column whose middle
|
||||||
|
* is right of the pointer. Over its own column or the near half of a neighbour, that is an edge
|
||||||
|
* of its own slot, where it would stay, so null. Left of the first column, over the fixed mark
|
||||||
|
* column, it is the first boundary.
|
||||||
|
*/
|
||||||
|
export function dropBoundary(
|
||||||
|
x: number,
|
||||||
|
cells: { left: number; right: number }[],
|
||||||
|
from: number,
|
||||||
|
): number | null {
|
||||||
|
let at = cells.findIndex((r) => x < (r.left + r.right) / 2);
|
||||||
|
if (at === -1) at = cells.length;
|
||||||
|
const stays = from === -1 || at === from || at === from + 1;
|
||||||
|
return stays ? null : at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far, in pixels, each of `count` shown columns slides while the one at `from`, `width`
|
||||||
|
* wide, is dragged to the boundary `to`: those it would pass make room for it, leftwards when it
|
||||||
|
* goes right and rightwards when it goes left. The dragged column itself, and the rest, stay.
|
||||||
|
*/
|
||||||
|
export function columnShifts(
|
||||||
|
count: number,
|
||||||
|
from: number,
|
||||||
|
width: number,
|
||||||
|
to: number | null,
|
||||||
|
): number[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
if (to == null || i === from) return 0;
|
||||||
|
if (to > from && i > from && i < to) return -width;
|
||||||
|
if (to < from && i >= to && i < from) return width;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows `id` alone */
|
/** Shows `id` alone */
|
||||||
@@ -227,20 +343,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs
|
|||||||
return { ...prefs, visible };
|
return { ...prefs, visible };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a column for a header or query parameter's path, shown at the end; already there, shown */
|
/**
|
||||||
|
* Adds a column for a header or query parameter's path, shown at the end; already there, shown,
|
||||||
|
* in its place
|
||||||
|
*/
|
||||||
export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs {
|
export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs {
|
||||||
const id = customColumnId(path);
|
const id = customColumnId(path);
|
||||||
const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id];
|
const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id];
|
||||||
return { ...prefs, custom, visible: { ...prefs.visible, [id]: true } };
|
return placeAtEnd({ ...prefs, custom, visible: { ...prefs.visible, [id]: true } }, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Takes the column out, and out of the sort */
|
/** Takes the column out, and out of the order and the sort */
|
||||||
export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs {
|
export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs {
|
||||||
const { [id]: _v, ...visible } = prefs.visible;
|
const { [id]: _v, ...visible } = prefs.visible;
|
||||||
const { [id]: _w, ...widths } = prefs.widths;
|
const { [id]: _w, ...widths } = prefs.widths;
|
||||||
const custom = prefs.custom.filter((c) => c !== id);
|
const custom = prefs.custom.filter((c) => c !== id);
|
||||||
|
const order = prefs.order.filter((c) => c !== id);
|
||||||
const sort = prefs.sort.filter((k) => k.column !== id);
|
const sort = prefs.sort.filter((k) => k.column !== id);
|
||||||
return { ...prefs, custom, visible, widths, sort };
|
return { ...prefs, custom, visible, widths, order, sort };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs {
|
export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs {
|
||||||
@@ -270,9 +390,9 @@ export function showOperationFor(prefs: ColumnPrefs, flows: Flow[]): ColumnPrefs
|
|||||||
* wrong type, so a change to the columns never breaks the list.
|
* wrong type, so a change to the columns never breaks the list.
|
||||||
*/
|
*/
|
||||||
export function parseColumnPrefs(value: unknown): ColumnPrefs {
|
export function parseColumnPrefs(value: unknown): ColumnPrefs {
|
||||||
const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] };
|
const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], order: [], sort: [] };
|
||||||
if (value == null || typeof value !== "object") return prefs;
|
if (value == null || typeof value !== "object") return prefs;
|
||||||
const { visible, widths, custom, sort } = value as Record<string, unknown>;
|
const { visible, widths, custom, order, sort } = value as Record<string, unknown>;
|
||||||
if (Array.isArray(custom)) {
|
if (Array.isArray(custom)) {
|
||||||
for (const id of custom) {
|
for (const id of custom) {
|
||||||
if (typeof id !== "string" || !id.startsWith("field:")) continue;
|
if (typeof id !== "string" || !id.startsWith("field:")) continue;
|
||||||
@@ -288,6 +408,15 @@ export function parseColumnPrefs(value: unknown): ColumnPrefs {
|
|||||||
const w = (widths as Record<string, unknown> | undefined)?.[def.id];
|
const w = (widths as Record<string, unknown> | undefined)?.[def.id];
|
||||||
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
|
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
|
||||||
}
|
}
|
||||||
|
// Columns no longer there are dropped; those not listed follow the rest in their default order
|
||||||
|
if (Array.isArray(order)) {
|
||||||
|
for (const column of order) {
|
||||||
|
if (typeof column !== "string") continue;
|
||||||
|
const id = column as ColumnId;
|
||||||
|
const known = isCustomColumn(id) ? prefs.custom.includes(id) : DEFS.has(id);
|
||||||
|
if (known && !prefs.order.includes(id)) prefs.order.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Array.isArray(sort)) {
|
if (Array.isArray(sort)) {
|
||||||
for (const key of sort) {
|
for (const key of sort) {
|
||||||
if (key == null || typeof key !== "object") continue;
|
if (key == null || typeof key !== "object") continue;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@tanstack/react-query": "^5.105.0",
|
"@tanstack/react-query": "^5.105.0",
|
||||||
"@tanstack/react-virtual": "^3.14.24",
|
"@tanstack/react-virtual": "^3.14.24",
|
||||||
"@tauri-apps/api": "^2.12.4",
|
"@tauri-apps/api": "^2.12.4",
|
||||||
|
|||||||
Generated
+1
@@ -246,6 +246,7 @@
|
|||||||
"name": "@yaakapp/yaak-proxy",
|
"name": "@yaakapp/yaak-proxy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@tanstack/react-query": "^5.105.0",
|
"@tanstack/react-query": "^5.105.0",
|
||||||
"@tanstack/react-virtual": "^3.14.24",
|
"@tanstack/react-virtual": "^3.14.24",
|
||||||
"@tauri-apps/api": "^2.12.4",
|
"@tauri-apps/api": "^2.12.4",
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** For the positioned element an `ActionsBar` sits in: hovering it brings the bar's actions in */
|
||||||
|
export const ACTIONS_BAR_CONTAINER = "relative group/actions";
|
||||||
|
|
||||||
|
/** Set on an action's outermost element to keep it in view, as a control that shows state does */
|
||||||
|
export const PINNED_ACTION = { "data-pinned": true } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where every bar sits in its container, the same for every viewer: flush with the content's
|
||||||
|
* right edge, which a text viewer's scrollbar sits just past, and lifted off the bottom edge
|
||||||
|
* above its horizontal scrollbar. Not overridable, so bars line up from pane to pane.
|
||||||
|
*/
|
||||||
|
const ACTIONS_BAR_INSET = "bottom-2 right-0 left-0";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of actions over the bottom-right corner of the content in an `ACTIONS_BAR_CONTAINER`:
|
||||||
|
* small bordered buttons on the surface colour, so they read over text, each held to the height
|
||||||
|
* and radius of an `sm` icon button and centred on the row. Each fades in while the
|
||||||
|
* container is hovered or the action has focus, unless it carries `PINNED_ACTION`. The row lets
|
||||||
|
* clicks through to the content between its actions. Place the container over the whole tab
|
||||||
|
* content, not a viewer's inner box, so the bar's inset is the pane's.
|
||||||
|
*/
|
||||||
|
export function ActionsBar({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"absolute z-10 flex items-center justify-end gap-1",
|
||||||
|
ACTIONS_BAR_INSET,
|
||||||
|
"pointer-events-none",
|
||||||
|
"*:pointer-events-auto *:bg-surface *:shadow *:transition-opacity *:transform-gpu",
|
||||||
|
// Every action as tall and as round as a small icon button, so they line up
|
||||||
|
"*:h-sm *:rounded-md",
|
||||||
|
"[&>:not([data-pinned])]:opacity-0",
|
||||||
|
"group-hover/actions:*:opacity-100! *:focus-within:opacity-100! *:hover:opacity-100!",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { FormatAction, type FormattableEditor, formatEditor } from "./FormatAction";
|
||||||
|
|
||||||
|
/** An editor as far as formatting reaches into one, recording its changes and blurs */
|
||||||
|
function fakeEditor(text: string) {
|
||||||
|
const changes: string[] = [];
|
||||||
|
const blur = vi.fn();
|
||||||
|
let state = EditorState.create({ doc: text });
|
||||||
|
const editor: FormattableEditor & { changes: string[]; blur: typeof blur } = {
|
||||||
|
get state() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
dispatch: ((spec: { changes: { from: number; to: number; insert: string } }) => {
|
||||||
|
state = state.update(spec).state;
|
||||||
|
changes.push(state.doc.toString());
|
||||||
|
}) as FormattableEditor["dispatch"],
|
||||||
|
contentDOM: { blur } as unknown as HTMLElement,
|
||||||
|
changes,
|
||||||
|
blur,
|
||||||
|
};
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatEditor", () => {
|
||||||
|
test("replaces the document with its formatted version and blurs", async () => {
|
||||||
|
const editor = fakeEditor('{"a":1}');
|
||||||
|
await formatEditor(editor, async (t) => JSON.stringify(JSON.parse(t), null, 2));
|
||||||
|
expect(editor.state.doc.toString()).toBe('{\n "a": 1\n}');
|
||||||
|
expect(editor.changes).toHaveLength(1);
|
||||||
|
expect(editor.blur).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a document that is already formatted alone", async () => {
|
||||||
|
const editor = fakeEditor("same");
|
||||||
|
await formatEditor(editor, async (t) => t);
|
||||||
|
expect(editor.changes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops a result for text that changed while it was formatting", async () => {
|
||||||
|
const editor = fakeEditor("before");
|
||||||
|
await formatEditor(editor, async () => {
|
||||||
|
editor.dispatch({ changes: { from: 0, to: 6, insert: "typed" } });
|
||||||
|
return "formatted";
|
||||||
|
});
|
||||||
|
expect(editor.state.doc.toString()).toBe("typed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FormatAction", () => {
|
||||||
|
test("is offered for an editable editor only", () => {
|
||||||
|
const editor = { current: null };
|
||||||
|
const format = async (t: string) => t;
|
||||||
|
expect(renderToStaticMarkup(<FormatAction editor={editor} format={format} />)).toContain(
|
||||||
|
"Reformat contents",
|
||||||
|
);
|
||||||
|
expect(renderToStaticMarkup(<FormatAction editor={editor} format={format} readOnly />)).toBe(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
|
/** What reformatting needs of an editor; the `EditorView` an editor's `setRef` hands over has it */
|
||||||
|
export type FormattableEditor = Pick<EditorView, "state" | "dispatch" | "contentDOM">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the editor's document with `format`'s version of it, and blurs it, since the cursor
|
||||||
|
* resets anyway. The change goes through the editor's own `onChange`, as typing does.
|
||||||
|
*/
|
||||||
|
export async function formatEditor(
|
||||||
|
editor: FormattableEditor,
|
||||||
|
format: (text: string) => Promise<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const before = editor.state.doc.toString();
|
||||||
|
const formatted = await format(before);
|
||||||
|
// Typed into while formatting: the result is out of date
|
||||||
|
if (editor.state.doc.toString() !== before) return;
|
||||||
|
if (formatted === before) return;
|
||||||
|
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: formatted } });
|
||||||
|
editor.contentDOM.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The editor to reformat, as its `setRef` set it */
|
||||||
|
editor: RefObject<FormattableEditor | null>;
|
||||||
|
format: (text: string) => Promise<string>;
|
||||||
|
/** A read-only editor can't be reformatted, so offers nothing */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reformats an editable editor's contents, for an `ActionsBar` */
|
||||||
|
export function FormatAction({ editor, format, readOnly }: Props) {
|
||||||
|
if (readOnly) return null;
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
showConfirm
|
||||||
|
size="sm"
|
||||||
|
title="Reformat contents"
|
||||||
|
icon="magic_wand"
|
||||||
|
variant="border"
|
||||||
|
onClick={async () => {
|
||||||
|
if (editor.current == null) return;
|
||||||
|
await formatEditor(editor.current, format);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+38
-13
@@ -1,19 +1,33 @@
|
|||||||
import type { IconProps } from "@yaakapp-internal/ui";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type ReactNode, useRef } from "react";
|
import { type ReactNode, useRef } from "react";
|
||||||
import { Button } from "./Button";
|
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||||
|
import { Button, CONTROL_TOOLTIP_DELAY } from "./Button";
|
||||||
|
import type { IconProps } from "./Icon";
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
import { IconTooltip } from "./IconTooltip";
|
||||||
|
import { Label } from "./Label";
|
||||||
|
import { HStack } from "./Stacks";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
interface Props<T extends string> {
|
export interface SegmentedControlOption<T extends string> {
|
||||||
options: { value: T; label: string; icon?: IconProps["icon"] }[];
|
value: T;
|
||||||
|
label: string;
|
||||||
|
icon?: IconProps["icon"];
|
||||||
|
/** Shown on hover over a text option, leaving its label as its name */
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentedControlProps<T extends string> {
|
||||||
|
options: SegmentedControlOption<T>[];
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
value: T;
|
value: T;
|
||||||
name: string;
|
name: string;
|
||||||
size?: IconButtonProps["size"];
|
size?: IconButtonProps["size"];
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Replaces the full width the control takes by default */
|
||||||
|
containerClassName?: string;
|
||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
labelClassName?: string;
|
labelClassName?: string;
|
||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
@@ -29,7 +43,8 @@ export function SegmentedControl<T extends string>({
|
|||||||
labelClassName,
|
labelClassName,
|
||||||
help,
|
help,
|
||||||
className,
|
className,
|
||||||
}: Props<T>) {
|
containerClassName = "w-full",
|
||||||
|
}: SegmentedControlProps<T>) {
|
||||||
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
|
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const id = useRef(`input-${generateId()}`);
|
const id = useRef(`input-${generateId()}`);
|
||||||
@@ -38,8 +53,13 @@ export function SegmentedControl<T extends string>({
|
|||||||
const inlineHelp =
|
const inlineHelp =
|
||||||
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
|
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
|
||||||
|
|
||||||
|
const focusOption = (index: number) => {
|
||||||
|
const option = containerRef.current?.querySelectorAll<HTMLElement>('[role="radio"]')[index];
|
||||||
|
option?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full grid">
|
<div className={classNames(containerClassName, "grid")}>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id.current}
|
htmlFor={id.current}
|
||||||
help={hideLabel ? undefined : help}
|
help={hideLabel ? undefined : help}
|
||||||
@@ -67,16 +87,14 @@ export function SegmentedControl<T extends string>({
|
|||||||
if (options[newIndex]) {
|
if (options[newIndex]) {
|
||||||
setSelectedValue(options[newIndex].value);
|
setSelectedValue(options[newIndex].value);
|
||||||
}
|
}
|
||||||
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
focusOption(newIndex);
|
||||||
child.focus();
|
|
||||||
} else if (e.key === "ArrowLeft") {
|
} else if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newIndex = Math.abs((selectedIndex - 1) % options.length);
|
const newIndex = Math.abs((selectedIndex - 1) % options.length);
|
||||||
if (options[newIndex]) {
|
if (options[newIndex]) {
|
||||||
setSelectedValue(options[newIndex].value);
|
setSelectedValue(options[newIndex].value);
|
||||||
}
|
}
|
||||||
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
focusOption(newIndex);
|
||||||
child.focus();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -85,7 +103,7 @@ export function SegmentedControl<T extends string>({
|
|||||||
const isActive = value === o.value;
|
const isActive = value === o.value;
|
||||||
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
||||||
if (o.icon == null) {
|
if (o.icon == null) {
|
||||||
return (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
key={o.label}
|
key={o.label}
|
||||||
aria-checked={isActive}
|
aria-checked={isActive}
|
||||||
@@ -104,6 +122,13 @@ export function SegmentedControl<T extends string>({
|
|||||||
{o.label}
|
{o.label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
return o.tooltip == null ? (
|
||||||
|
button
|
||||||
|
) : (
|
||||||
|
<Tooltip key={o.label} wrap content={o.tooltip} delay={CONTROL_TOOLTIP_DELAY}>
|
||||||
|
{button}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
|
||||||
|
import { CsvViewerInner } from "./viewers/CsvViewer";
|
||||||
|
|
||||||
|
function table(density?: "default" | "compact") {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<Table density={density}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>Name</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Accept</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Table density", () => {
|
||||||
|
test("a compact table trims its cells' and header's padding", () => {
|
||||||
|
const markup = table("compact");
|
||||||
|
expect(markup).toContain('data-density="compact"');
|
||||||
|
expect(markup).toContain("[&_td]:py-0.5");
|
||||||
|
expect(markup).toContain("[&_th]:py-0.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other tables keep their spacing", () => {
|
||||||
|
const markup = table();
|
||||||
|
expect(markup).toContain('data-density="default"');
|
||||||
|
expect(markup).not.toContain("py-0.5");
|
||||||
|
expect(markup).toContain("py-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CSV viewer's table is compact", () => {
|
||||||
|
expect(renderToStaticMarkup(<CsvViewerInner text={"a,b\n1,2"} />)).toContain(
|
||||||
|
'data-density="compact"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,11 +6,14 @@ export function Table({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
scrollable,
|
scrollable,
|
||||||
|
density = "default",
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
|
/** Compact rows, as dense as the proxy's flow list, for tables of data rather than settings */
|
||||||
|
density?: "default" | "compact";
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +24,10 @@ export function Table({
|
|||||||
"w-full text-sm mb-auto min-w-full max-w-full",
|
"w-full text-sm mb-auto min-w-full max-w-full",
|
||||||
"border-separate border-spacing-0",
|
"border-separate border-spacing-0",
|
||||||
scrollable && "[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10",
|
scrollable && "[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10",
|
||||||
|
// Overrides the cells' own padding, which outranks theirs as it names the table too
|
||||||
|
density === "compact" && "[&_td]:py-0.5 [&_th]:py-0.5",
|
||||||
)}
|
)}
|
||||||
|
data-density={density}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { ActionsBar } from "../ActionsBar";
|
||||||
|
import { IconButton } from "../IconButton";
|
||||||
|
import { BodyViewModeSwitch, prettyViewFor, viewModeLabels } from "./BodyViewModeSwitch";
|
||||||
|
|
||||||
|
describe("prettyViewFor", () => {
|
||||||
|
test("names the formatted view for each format", () => {
|
||||||
|
const cases: [string | null, string | null, boolean, string | null][] = [
|
||||||
|
["application/json", "json", false, "formatted"],
|
||||||
|
["application/xml", "xml", false, "formatted"],
|
||||||
|
["text/html", "html", false, "formatted"],
|
||||||
|
["text/html", "html", true, "preview"],
|
||||||
|
["text/csv", "text", false, "table"],
|
||||||
|
["text/tab-separated-values", "text", false, "table"],
|
||||||
|
["multipart/form-data", null, false, "parts"],
|
||||||
|
["image/svg+xml", "xml", false, "preview"],
|
||||||
|
];
|
||||||
|
for (const [mimeType, language, previewHtml, expected] of cases) {
|
||||||
|
expect(prettyViewFor({ mimeType, language, previewHtml }), `${mimeType}`).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body with one view has no switch", () => {
|
||||||
|
expect(prettyViewFor({ mimeType: "text/plain", language: "text" })).toBeNull();
|
||||||
|
expect(prettyViewFor({ mimeType: null, language: "text" })).toBeNull();
|
||||||
|
expect(prettyViewFor({ mimeType: "application/javascript", language: "javascript" })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body's text decides over a missing content type", () => {
|
||||||
|
expect(prettyViewFor({ mimeType: null, language: "json" })).toBe("formatted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BodyViewModeSwitch", () => {
|
||||||
|
test("its first state is named for the format, its second is Raw", () => {
|
||||||
|
expect(viewModeLabels("formatted")).toEqual(["Pretty", "Raw"]);
|
||||||
|
expect(viewModeLabels("table")).toEqual(["Table", "Raw"]);
|
||||||
|
expect(viewModeLabels("parts")).toEqual(["Parts", "Raw"]);
|
||||||
|
expect(viewModeLabels("preview")).toEqual(["Preview", "Raw"]);
|
||||||
|
expect(viewModeLabels("form")).toEqual(["Form", "Raw"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks the chosen state, and stays in view in an actions bar", () => {
|
||||||
|
const pretty = renderToStaticMarkup(
|
||||||
|
<BodyViewModeSwitch pretty="table" value="pretty" onChange={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(pretty).toContain("data-pinned");
|
||||||
|
expect(pretty).toMatch(/aria-checked="true"[^>]*>.*?Table/);
|
||||||
|
expect(pretty).toMatch(/aria-checked="false"[^>]*>.*?Raw/);
|
||||||
|
|
||||||
|
const raw = renderToStaticMarkup(
|
||||||
|
<BodyViewModeSwitch pretty="table" value="raw" onChange={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(raw).toMatch(/aria-checked="false"[^>]*>.*?Table/);
|
||||||
|
expect(raw).toMatch(/aria-checked="true"[^>]*>.*?Raw/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ActionsBar", () => {
|
||||||
|
test("fades its actions in on hover, except a pinned one", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<ActionsBar>
|
||||||
|
<IconButton icon="filter" title="Filter" />
|
||||||
|
<BodyViewModeSwitch pretty="formatted" value="pretty" onChange={() => {}} />
|
||||||
|
</ActionsBar>,
|
||||||
|
);
|
||||||
|
expect(markup).toContain("[&>:not([data-pinned])]:opacity-0");
|
||||||
|
expect(markup).toContain("group-hover/actions:*:opacity-100!");
|
||||||
|
expect(markup).toContain('title="Filter"');
|
||||||
|
expect(markup).toContain("data-pinned");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("holds every action to one height and radius, centred on the row", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<ActionsBar>
|
||||||
|
<IconButton icon="filter" title="Filter" />
|
||||||
|
</ActionsBar>,
|
||||||
|
);
|
||||||
|
expect(markup).toContain("items-center");
|
||||||
|
expect(markup).toContain("*:h-sm");
|
||||||
|
expect(markup).toContain("*:rounded-md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the switch is sized to the bar's small icon buttons", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<BodyViewModeSwitch pretty="formatted" value="pretty" onChange={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(markup).toContain("h-sm!");
|
||||||
|
expect(markup).toContain("[&_[role=radio]]:h-full!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { PINNED_ACTION } from "../ActionsBar";
|
||||||
|
import { SegmentedControl } from "../SegmentedControl";
|
||||||
|
import type { ResponseViewMode } from "./ResponseBodyViewer";
|
||||||
|
|
||||||
|
/** What a body's formatted view shows, which names it in the switch */
|
||||||
|
export type PrettyView = "formatted" | "table" | "parts" | "preview" | "form";
|
||||||
|
|
||||||
|
const PRETTY_OPTIONS: Record<PrettyView, { label: string; tooltip: string }> = {
|
||||||
|
formatted: { label: "Pretty", tooltip: "Formatted" },
|
||||||
|
table: { label: "Table", tooltip: "As a table" },
|
||||||
|
parts: { label: "Parts", tooltip: "Split into parts" },
|
||||||
|
preview: { label: "Preview", tooltip: "Rendered" },
|
||||||
|
form: { label: "Form", tooltip: "As fields" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAW_OPTION = { label: "Raw", tooltip: "As received" };
|
||||||
|
|
||||||
|
/** The switch's labels for a body whose formatted view is `pretty`, its first state first */
|
||||||
|
export function viewModeLabels(pretty: PrettyView): [string, string] {
|
||||||
|
return [PRETTY_OPTIONS[pretty].label, RAW_OPTION.label];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The formatted view a text body has, or null when it has only the one view: a table for CSV,
|
||||||
|
* parts for multipart, a rendering for SVG (and HTML, where `previewHtml`), and formatted text
|
||||||
|
* for JSON, XML and HTML.
|
||||||
|
*/
|
||||||
|
export function prettyViewFor({
|
||||||
|
mimeType,
|
||||||
|
language,
|
||||||
|
previewHtml = false,
|
||||||
|
}: {
|
||||||
|
/** The media type, without parameters */
|
||||||
|
mimeType: string | null;
|
||||||
|
/** The language the text viewer would show the body in */
|
||||||
|
language: string | null | undefined;
|
||||||
|
/** HTML renders as a page, rather than formatted text */
|
||||||
|
previewHtml?: boolean;
|
||||||
|
}): PrettyView | null {
|
||||||
|
const mime = mimeType?.toLowerCase() ?? "";
|
||||||
|
if (mime.startsWith("multipart/")) return "parts";
|
||||||
|
if (/csv|tab-separated/.test(mime)) return "table";
|
||||||
|
if (mime.startsWith("image/svg")) return "preview";
|
||||||
|
if (language === "html") return previewHtml ? "preview" : "formatted";
|
||||||
|
if (language === "json" || language === "xml") return "formatted";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The body's formatted view, named in the first state */
|
||||||
|
pretty: PrettyView;
|
||||||
|
value: ResponseViewMode;
|
||||||
|
onChange: (value: ResponseViewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows a body formatted for its type, or raw as received; for an `ActionsBar`, always in view */
|
||||||
|
export function BodyViewModeSwitch({ pretty, value, onChange }: Props) {
|
||||||
|
const prettyOption = PRETTY_OPTIONS[pretty];
|
||||||
|
return (
|
||||||
|
<div {...PINNED_ACTION} className="rounded-md">
|
||||||
|
<SegmentedControl<ResponseViewMode>
|
||||||
|
name="body_view_mode"
|
||||||
|
label="View"
|
||||||
|
hideLabel
|
||||||
|
size="2xs"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
containerClassName="w-auto h-full"
|
||||||
|
// As tall as the bar's `sm` icon buttons, its options filling the height inside the border
|
||||||
|
className={classNames(
|
||||||
|
"h-sm! m-0! p-0.5! gap-0.5! rounded-md! bg-surface! border border-border-subtle",
|
||||||
|
"[&_[role=radio]]:h-full!",
|
||||||
|
)}
|
||||||
|
options={[
|
||||||
|
{ value: "pretty", ...prettyOption },
|
||||||
|
{ value: "raw", ...RAW_OPTION },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ interface Props {
|
|||||||
renderCopyButton?: (getText: () => Promise<string | null>) => ReactNode;
|
renderCopyButton?: (getText: () => Promise<string | null>) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
/** Bytes above which a body is held back until asked for, and opens raw once shown */
|
||||||
|
export const LARGE_BODY_BYTES = 2 * 1000 * 1000;
|
||||||
|
|
||||||
/** Holds back a body big enough to slow the window down, until asked to show it */
|
/** Holds back a body big enough to slow the window down, until asked to show it */
|
||||||
export function ConfirmLargeResponse({
|
export function ConfirmLargeResponse({
|
||||||
@@ -40,14 +41,14 @@ export function ConfirmLargeResponse({
|
|||||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||||
const isProbablyText = useMemo(() => isProbablyTextContentType(contentType), [contentType]);
|
const isProbablyText = useMemo(() => isProbablyTextContentType(contentType), [contentType]);
|
||||||
|
|
||||||
const isLarge = (contentLength ?? 0) > LARGE_BYTES;
|
const isLarge = (contentLength ?? 0) > LARGE_BODY_BYTES;
|
||||||
if (!showLargeResponse && isLarge) {
|
if (!showLargeResponse && isLarge) {
|
||||||
return (
|
return (
|
||||||
<Banner color="primary" className="flex flex-col gap-3">
|
<Banner color="primary" className="flex flex-col gap-3">
|
||||||
<p>
|
<p>
|
||||||
Showing {noun} over{" "}
|
Showing {noun} over{" "}
|
||||||
<InlineCode>
|
<InlineCode>
|
||||||
<SizeTag contentLength={LARGE_BYTES} />
|
<SizeTag contentLength={LARGE_BODY_BYTES} />
|
||||||
</InlineCode>{" "}
|
</InlineCode>{" "}
|
||||||
may impact performance
|
may impact performance
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { type ReactNode, useCallback, useMemo } from "react";
|
||||||
import type { Pair, PairEditorProps } from "../pairs/PairEditor";
|
import type { Pair, PairEditorProps } from "../pairs/PairEditor";
|
||||||
import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor";
|
import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor";
|
||||||
import type { HttpFormFieldView, HttpRequestBodyView } from "./types";
|
import type { HttpFormFieldView, HttpRequestBodyView } from "./types";
|
||||||
@@ -11,6 +11,8 @@ type Props = {
|
|||||||
onChange?: (body: HttpRequestBodyView) => void;
|
onChange?: (body: HttpRequestBodyView) => void;
|
||||||
/** Show the fields without a way to change them */
|
/** Show the fields without a way to change them */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
/** Drawn in one bar with the bulk-edit toggle, like a view-mode switch */
|
||||||
|
actions?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FormUrlencodedEditor({
|
export function FormUrlencodedEditor({
|
||||||
@@ -19,6 +21,7 @@ export function FormUrlencodedEditor({
|
|||||||
forceUpdateKey,
|
forceUpdateKey,
|
||||||
onChange,
|
onChange,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
actions,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pairs = useMemo<Pair[]>(
|
const pairs = useMemo<Pair[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -52,6 +55,7 @@ export function FormUrlencodedEditor({
|
|||||||
pairs={pairs}
|
pairs={pairs}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
actions={actions}
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
stateKey={`urlencoded.${requestId}`}
|
stateKey={`urlencoded.${requestId}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ export interface HttpRequestPaneViewProps {
|
|||||||
|
|
||||||
/** Replaces the URL bar, as the proxy's card shows a captured request's method and URL */
|
/** Replaces the URL bar, as the proxy's card shows a captured request's method and URL */
|
||||||
urlLine?: ReactNode;
|
urlLine?: ReactNode;
|
||||||
/** Tabs with nothing in them read in the subtlest colour */
|
|
||||||
dimEmptyTabs?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* The tab to open on for a request with no tab of its own; with it, a tab chosen for one
|
* The tab to open on for a request with no tab of its own; with it, a tab chosen for one
|
||||||
* request doesn't carry to the next
|
* request doesn't carry to the next
|
||||||
@@ -168,7 +166,6 @@ export function HttpRequestPaneView({
|
|||||||
authContent,
|
authContent,
|
||||||
extraTabs,
|
extraTabs,
|
||||||
urlLine,
|
urlLine,
|
||||||
dimEmptyTabs = false,
|
|
||||||
defaultTab,
|
defaultTab,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -193,20 +190,17 @@ export function HttpRequestPaneView({
|
|||||||
bodyTab ?? {
|
bodyTab ?? {
|
||||||
value: REQUEST_TAB_BODY,
|
value: REQUEST_TAB_BODY,
|
||||||
label: bodyTypeLabel(request.bodyType, request.contentType),
|
label: bodyTypeLabel(request.bodyType, request.contentType),
|
||||||
empty: dimEmptyTabs && request.bodyType == null,
|
|
||||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: REQUEST_TAB_PARAMS,
|
value: REQUEST_TAB_PARAMS,
|
||||||
rightSlot: <CountBadge count={paramPairs.length} />,
|
rightSlot: <CountBadge count={paramPairs.length} />,
|
||||||
label: "Params",
|
label: "Params",
|
||||||
empty: dimEmptyTabs && paramPairs.length === 0,
|
|
||||||
},
|
},
|
||||||
...(headersTabs ?? [
|
...(headersTabs ?? [
|
||||||
{
|
{
|
||||||
value: REQUEST_TAB_HEADERS,
|
value: REQUEST_TAB_HEADERS,
|
||||||
label: "Headers",
|
label: "Headers",
|
||||||
empty: dimEmptyTabs && headerCount === 0,
|
|
||||||
rightSlot: <CountBadge count={headerCount} />,
|
rightSlot: <CountBadge count={headerCount} />,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@@ -224,7 +218,6 @@ export function HttpRequestPaneView({
|
|||||||
[
|
[
|
||||||
authTabs,
|
authTabs,
|
||||||
bodyTab,
|
bodyTab,
|
||||||
dimEmptyTabs,
|
|
||||||
extraTabs,
|
extraTabs,
|
||||||
headerCount,
|
headerCount,
|
||||||
headersTabs,
|
headersTabs,
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ export interface HttpResponsePaneViewProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Shown before there's a response */
|
/** Shown before there's a response */
|
||||||
emptyState: ReactNode;
|
emptyState: ReactNode;
|
||||||
viewMode: ResponseViewMode;
|
viewMode?: ResponseViewMode;
|
||||||
onViewModeChange: (viewMode: ResponseViewMode) => void;
|
/**
|
||||||
|
* Offers the raw view in the Response tab's menu. Left out where the body tab has a view-mode
|
||||||
|
* switch of its own, as the proxy's does
|
||||||
|
*/
|
||||||
|
onViewModeChange?: (viewMode: ResponseViewMode) => void;
|
||||||
/** Where the tab order and the active tab are remembered */
|
/** Where the tab order and the active tab are remembered */
|
||||||
tabsStorageKey?: string;
|
tabsStorageKey?: string;
|
||||||
/** Remembers the active tab per request or flow */
|
/** Remembers the active tab per request or flow */
|
||||||
@@ -76,8 +80,6 @@ export interface HttpResponsePaneViewProps {
|
|||||||
requestShownApart?: boolean;
|
requestShownApart?: boolean;
|
||||||
/** Drawn inside a card the app draws, without a card of its own */
|
/** Drawn inside a card the app draws, without a card of its own */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
/** Tabs with nothing in them read in the subtlest colour */
|
|
||||||
dimEmptyTabs?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* The tab to open on for a key with no tab of its own; with it, a tab chosen under one key
|
* The tab to open on for a key with no tab of its own; with it, a tab chosen under one key
|
||||||
* doesn't carry to the next
|
* doesn't carry to the next
|
||||||
@@ -97,7 +99,7 @@ export function HttpResponsePaneView({
|
|||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
emptyState,
|
emptyState,
|
||||||
viewMode,
|
viewMode = "pretty",
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
tabsStorageKey = "http_response_tabs",
|
tabsStorageKey = "http_response_tabs",
|
||||||
activeTabKey,
|
activeTabKey,
|
||||||
@@ -115,7 +117,6 @@ export function HttpResponsePaneView({
|
|||||||
renderCopyButton,
|
renderCopyButton,
|
||||||
requestShownApart = false,
|
requestShownApart = false,
|
||||||
bare = false,
|
bare = false,
|
||||||
dimEmptyTabs = false,
|
|
||||||
defaultTab,
|
defaultTab,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -162,27 +163,24 @@ export function HttpResponsePaneView({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const noBody = response == null || (response.contentLength ?? 0) === 0;
|
|
||||||
const headerCount = response?.headers.length ?? 0;
|
const headerCount = response?.headers.length ?? 0;
|
||||||
const cookieCount = requestShownApart
|
|
||||||
? cookieCounts.received
|
|
||||||
: cookieCounts.sent + cookieCounts.received;
|
|
||||||
return [
|
return [
|
||||||
{
|
onViewModeChange == null && itemsAfter.length === 0
|
||||||
value: RESPONSE_TAB_BODY,
|
? { value: RESPONSE_TAB_BODY, label: "Response" }
|
||||||
empty: dimEmptyTabs && noBody,
|
: {
|
||||||
options: {
|
value: RESPONSE_TAB_BODY,
|
||||||
value: viewMode,
|
options: {
|
||||||
onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"),
|
value: viewMode,
|
||||||
items: [
|
onChange: (v: string | null) => onViewModeChange?.(v === "raw" ? "raw" : "pretty"),
|
||||||
{ label: "Response", value: "pretty" },
|
items: [
|
||||||
...(mimeType?.startsWith("image")
|
{ label: "Response", value: "pretty" },
|
||||||
? []
|
...(onViewModeChange == null || mimeType?.startsWith("image")
|
||||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
? []
|
||||||
],
|
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
||||||
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
|
],
|
||||||
},
|
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
...(requestShownApart
|
...(requestShownApart
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
@@ -196,7 +194,6 @@ export function HttpResponsePaneView({
|
|||||||
{
|
{
|
||||||
value: RESPONSE_TAB_HEADERS,
|
value: RESPONSE_TAB_HEADERS,
|
||||||
label: "Headers",
|
label: "Headers",
|
||||||
empty: dimEmptyTabs && headerCount === 0,
|
|
||||||
rightSlot: requestShownApart ? (
|
rightSlot: requestShownApart ? (
|
||||||
<CountBadge count={headerCount} />
|
<CountBadge count={headerCount} />
|
||||||
) : (
|
) : (
|
||||||
@@ -206,7 +203,6 @@ export function HttpResponsePaneView({
|
|||||||
{
|
{
|
||||||
value: RESPONSE_TAB_COOKIES,
|
value: RESPONSE_TAB_COOKIES,
|
||||||
label: "Cookies",
|
label: "Cookies",
|
||||||
empty: dimEmptyTabs && cookieCount === 0,
|
|
||||||
rightSlot: requestShownApart ? (
|
rightSlot: requestShownApart ? (
|
||||||
<CountBadge count={cookieCounts.received} />
|
<CountBadge count={cookieCounts.received} />
|
||||||
) : cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
) : cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||||
@@ -217,7 +213,6 @@ export function HttpResponsePaneView({
|
|||||||
];
|
];
|
||||||
}, [
|
}, [
|
||||||
cookieCounts,
|
cookieCounts,
|
||||||
dimEmptyTabs,
|
|
||||||
extraTabs,
|
extraTabs,
|
||||||
mimeType,
|
mimeType,
|
||||||
onCopyAsCurl,
|
onCopyAsCurl,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import type { HostInputProps } from "../EditorHost";
|
||||||
|
import { EditorHostProvider } from "../EditorHost";
|
||||||
|
import { PairOrBulkEditor } from "./PairOrBulkEditor";
|
||||||
|
|
||||||
|
function StubInput({ name, defaultValue }: HostInputProps) {
|
||||||
|
return <div data-input={name}>{defaultValue}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(actions?: string) {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<EditorHostProvider value={{ Input: StubInput }}>
|
||||||
|
<PairOrBulkEditor
|
||||||
|
readOnly
|
||||||
|
preferenceName="test"
|
||||||
|
stateKey="test"
|
||||||
|
forceUpdateKey=""
|
||||||
|
pairs={[{ id: "p1", enabled: true, name: "a", value: "1" }]}
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={actions == null ? undefined : <span data-action>{actions}</span>}
|
||||||
|
/>
|
||||||
|
</EditorHostProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PairOrBulkEditor", () => {
|
||||||
|
test("with actions, its bulk toggle shares one actions bar with them", () => {
|
||||||
|
const markup = render("SWITCH");
|
||||||
|
expect(markup).toContain("group/actions");
|
||||||
|
// The toggle, then the given actions, inside the bar
|
||||||
|
expect(markup).toMatch(/group-hover\/actions[^>]*>.*Enable bulk edit.*SWITCH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without actions, its toggle keeps its own corner", () => {
|
||||||
|
const markup = render();
|
||||||
|
expect(markup).not.toContain("group/actions");
|
||||||
|
expect(markup).toContain("Enable bulk edit");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar";
|
||||||
import { useEditorHost } from "../EditorHost";
|
import { useEditorHost } from "../EditorHost";
|
||||||
import { IconButton } from "../IconButton";
|
import { IconButton } from "../IconButton";
|
||||||
import { BulkPairEditor } from "./BulkPairEditor";
|
import { BulkPairEditor } from "./BulkPairEditor";
|
||||||
@@ -8,9 +10,14 @@ import { PairEditor } from "./PairEditor";
|
|||||||
interface Props extends PairEditorProps {
|
interface Props extends PairEditorProps {
|
||||||
preferenceName: string;
|
preferenceName: string;
|
||||||
forcedEnvironmentId?: string;
|
forcedEnvironmentId?: string;
|
||||||
|
/**
|
||||||
|
* Drawn after the bulk-edit toggle in one `ActionsBar`, like a view-mode switch. Giving it puts
|
||||||
|
* the toggle in that bar rather than its own corner; once every site has moved, it always is
|
||||||
|
*/
|
||||||
|
actions?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
|
export function PairOrBulkEditor({ preferenceName, actions, ...props }: Props) {
|
||||||
const { useStoredValue } = useEditorHost();
|
const { useStoredValue } = useEditorHost();
|
||||||
const [useBulk, setUseBulk] = useStoredValue<boolean>(
|
const [useBulk, setUseBulk] = useStoredValue<boolean>(
|
||||||
["bulk_edit", preferenceName],
|
["bulk_edit", preferenceName],
|
||||||
@@ -18,20 +25,37 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
|
|||||||
"global",
|
"global",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const editor = useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />;
|
||||||
|
const toggleProps = {
|
||||||
|
size: "sm",
|
||||||
|
variant: "border",
|
||||||
|
title: useBulk ? "Enable form edit" : "Enable bulk edit",
|
||||||
|
onClick: () => setUseBulk((b) => !b),
|
||||||
|
icon: useBulk ? "table" : "file_code",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (actions != null) {
|
||||||
|
return (
|
||||||
|
<div className={classNames(ACTIONS_BAR_CONTAINER, "h-full w-full")}>
|
||||||
|
{editor}
|
||||||
|
<ActionsBar>
|
||||||
|
<IconButton {...toggleProps} />
|
||||||
|
{actions}
|
||||||
|
</ActionsBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full group/wrapper">
|
<div className="relative h-full w-full group/wrapper">
|
||||||
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
|
{editor}
|
||||||
<div className="absolute right-0 bottom-0">
|
<div className="absolute right-0 bottom-0">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="sm"
|
{...toggleProps}
|
||||||
variant="border"
|
|
||||||
title={useBulk ? "Enable form edit" : "Enable bulk edit"}
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
|
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
|
||||||
"bg-surface hover:text group-hover/wrapper:opacity-100",
|
"bg-surface hover:text group-hover/wrapper:opacity-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => setUseBulk((b) => !b)}
|
|
||||||
icon={useBulk ? "table" : "file_code"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export type TabItem =
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
/** Nothing in it: the label reads in the subtlest colour */
|
|
||||||
empty?: boolean;
|
|
||||||
leftSlot?: ReactNode;
|
leftSlot?: ReactNode;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -59,8 +57,6 @@ export type TabItem =
|
|||||||
export interface TabOptionsItem {
|
export interface TabOptionsItem {
|
||||||
value: string;
|
value: string;
|
||||||
options: Omit<RadioDropdownProps, "children">;
|
options: Omit<RadioDropdownProps, "children">;
|
||||||
/** Nothing in it: the label reads in the subtlest colour */
|
|
||||||
empty?: boolean;
|
|
||||||
leftSlot?: ReactNode;
|
leftSlot?: ReactNode;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -526,15 +522,7 @@ function TabButton({
|
|||||||
) : "options" in tab ? (
|
) : "options" in tab ? (
|
||||||
<TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} />
|
<TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||||
leftSlot={tab.leftSlot}
|
|
||||||
rightSlot={tab.rightSlot}
|
|
||||||
{...btnProps}
|
|
||||||
className={classNames(
|
|
||||||
btnProps.className,
|
|
||||||
!isActive && "empty" in tab && tab.empty && "text-text-subtlest!",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{"label" in tab && tab.label ? tab.label : tab.value}
|
{"label" in tab && tab.label ? tab.label : tab.value}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -607,7 +595,6 @@ function TabOptionsButton({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
className={classNames(buttonProps.className, !isActive && tab.empty && "text-text-subtlest!")}
|
|
||||||
>
|
>
|
||||||
{option && "shortLabel" in option && option.shortLabel
|
{option && "shortLabel" in option && option.shortLabel
|
||||||
? option.shortLabel
|
? option.shortLabel
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function CsvViewerInner({ text, className }: { text: string | null; class
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="overflow-auto h-full">
|
||||||
<Table className={classNames(className, "text-sm")}>
|
<Table density="compact" className={classNames(className, "text-sm")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columnIndexes.map((columnIndex) => (
|
{columnIndexes.map((columnIndex) => (
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ function LeafBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType?.match(/csv|tab-separated/i)) {
|
if (mediaType?.match(/csv|tab-separated/i)) {
|
||||||
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
|
return <CsvViewer text={content} className="pb-2" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) {
|
if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Extension } from "@codemirror/state";
|
import type { Extension } from "@codemirror/state";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Children, useCallback, useMemo, useState } from "react";
|
import { Children, useCallback, useMemo, useState } from "react";
|
||||||
import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter";
|
import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter";
|
||||||
import { docFingerprint } from "../../lib/docFingerprint";
|
import { docFingerprint } from "../../lib/docFingerprint";
|
||||||
|
import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar";
|
||||||
import type { CodeEditorProps } from "../editor/CodeEditor";
|
import type { CodeEditorProps } from "../editor/CodeEditor";
|
||||||
import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension";
|
import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension";
|
||||||
import type { JsonPathSegment } from "../editor/json/jsonPath";
|
import type { JsonPathSegment } from "../editor/json/jsonPath";
|
||||||
@@ -20,6 +22,13 @@ export interface TextViewerProps {
|
|||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
footerActions?: ReactNode;
|
footerActions?: ReactNode;
|
||||||
|
/** Last of the actions and kept while filtering, like a view-mode switch */
|
||||||
|
pinnedActions?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Draws the actions in an `ActionsBar` over the viewer rather than in the editor's own row.
|
||||||
|
* Only the proxy's body tabs set it for now; once every site has moved it goes away
|
||||||
|
*/
|
||||||
|
ownActionsBar?: boolean;
|
||||||
extensions?: Extension[];
|
extensions?: Extension[];
|
||||||
/** Pretty-prints the text when `pretty` is set */
|
/** Pretty-prints the text when `pretty` is set */
|
||||||
formatText?: (text: string, language: CodeEditorProps["language"]) => Promise<string>;
|
formatText?: (text: string, language: CodeEditorProps["language"]) => Promise<string>;
|
||||||
@@ -42,6 +51,8 @@ export function TextViewer({
|
|||||||
pretty,
|
pretty,
|
||||||
className,
|
className,
|
||||||
footerActions,
|
footerActions,
|
||||||
|
pinnedActions,
|
||||||
|
ownActionsBar = false,
|
||||||
extensions,
|
extensions,
|
||||||
formatText,
|
formatText,
|
||||||
filterText,
|
filterText,
|
||||||
@@ -121,8 +132,9 @@ export function TextViewer({
|
|||||||
|
|
||||||
const actions = useMemo<ReactNode[]>(() => {
|
const actions = useMemo<ReactNode[]>(() => {
|
||||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||||
|
const pinned = Children.toArray(pinnedActions);
|
||||||
|
|
||||||
if (!canFilter) return nodes;
|
if (!canFilter) return [...nodes, ...pinned];
|
||||||
|
|
||||||
if (isSearching) {
|
if (isSearching) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
@@ -173,10 +185,11 @@ export function TextViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes;
|
return [...nodes, ...pinned];
|
||||||
}, [
|
}, [
|
||||||
canFilter,
|
canFilter,
|
||||||
footerActions,
|
footerActions,
|
||||||
|
pinnedActions,
|
||||||
filter,
|
filter,
|
||||||
filterResult.isPending,
|
filterResult.isPending,
|
||||||
filterResult.isFetching,
|
filterResult.isFetching,
|
||||||
@@ -221,16 +234,19 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div
|
||||||
|
className={classNames("h-full w-full", ownActionsBar ? ACTIONS_BAR_CONTAINER : "relative")}
|
||||||
|
>
|
||||||
{renderEditor({
|
{renderEditor({
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
className,
|
className,
|
||||||
defaultValue: body,
|
defaultValue: body,
|
||||||
language,
|
language,
|
||||||
actions,
|
actions: ownActionsBar ? undefined : actions,
|
||||||
extensions: extraExtensions,
|
extensions: extraExtensions,
|
||||||
stateKey,
|
stateKey,
|
||||||
})}
|
})}
|
||||||
|
{ownActionsBar && actions.length > 0 && <ActionsBar>{actions}</ActionsBar>}
|
||||||
{showBreadcrumbs && (
|
{showBreadcrumbs && (
|
||||||
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
|
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
|
||||||
<ResponseBreadcrumbBar
|
<ResponseBreadcrumbBar
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { ACTIONS_BAR_CONTAINER, ActionsBar, PINNED_ACTION } from "./components/ActionsBar";
|
||||||
export type { BannerProps } from "./components/Banner";
|
export type { BannerProps } from "./components/Banner";
|
||||||
export { Banner } from "./components/Banner";
|
export { Banner } from "./components/Banner";
|
||||||
export type { ButtonProps } from "./components/Button";
|
export type { ButtonProps } from "./components/Button";
|
||||||
@@ -61,7 +62,13 @@ export type {
|
|||||||
export { eventDeltas, formatEventDelta, formatEventTime } from "./components/events/streamEvents";
|
export { eventDeltas, formatEventDelta, formatEventTime } from "./components/events/streamEvents";
|
||||||
export type { ExternalLinkProps } from "./components/ExternalLink";
|
export type { ExternalLinkProps } from "./components/ExternalLink";
|
||||||
export { ExternalLink } from "./components/ExternalLink";
|
export { ExternalLink } from "./components/ExternalLink";
|
||||||
export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse";
|
export type { PrettyView } from "./components/http/BodyViewModeSwitch";
|
||||||
|
export {
|
||||||
|
BodyViewModeSwitch,
|
||||||
|
prettyViewFor,
|
||||||
|
viewModeLabels,
|
||||||
|
} from "./components/http/BodyViewModeSwitch";
|
||||||
|
export { ConfirmLargeResponse, LARGE_BODY_BYTES } from "./components/http/ConfirmLargeResponse";
|
||||||
export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
|
export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
|
||||||
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
|
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
|
||||||
export { HeadersEditor } from "./components/http/HeadersEditor";
|
export { HeadersEditor } from "./components/http/HeadersEditor";
|
||||||
@@ -126,6 +133,8 @@ export type { SelectProps } from "./components/Select";
|
|||||||
export { Select } from "./components/Select";
|
export { Select } from "./components/Select";
|
||||||
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
|
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
|
||||||
export { ErrorBoundary } from "./components/ErrorBoundary";
|
export { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
export type { FormattableEditor } from "./components/FormatAction";
|
||||||
|
export { FormatAction, formatEditor } from "./components/FormatAction";
|
||||||
export { FormattedError } from "./components/FormattedError";
|
export { FormattedError } from "./components/FormattedError";
|
||||||
export { HeaderSize } from "./components/HeaderSize";
|
export { HeaderSize } from "./components/HeaderSize";
|
||||||
export type { HeadersTableProps } from "./components/HeadersTable";
|
export type { HeadersTableProps } from "./components/HeadersTable";
|
||||||
@@ -148,6 +157,8 @@ export { Overlay } from "./components/Overlay";
|
|||||||
export type { PlainInputProps } from "./components/PlainInput";
|
export type { PlainInputProps } from "./components/PlainInput";
|
||||||
export { PlainInput } from "./components/PlainInput";
|
export { PlainInput } from "./components/PlainInput";
|
||||||
export { Portal } from "./components/Portal";
|
export { Portal } from "./components/Portal";
|
||||||
|
export type { SegmentedControlOption, SegmentedControlProps } from "./components/SegmentedControl";
|
||||||
|
export { SegmentedControl } from "./components/SegmentedControl";
|
||||||
export type { SeparatorAction } from "./components/Separator";
|
export type { SeparatorAction } from "./components/Separator";
|
||||||
export { Separator } from "./components/Separator";
|
export { Separator } from "./components/Separator";
|
||||||
export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow";
|
export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow";
|
||||||
|
|||||||
Reference in new issue
Block a user