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
@@ -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 type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
ACTIONS_BAR_CONTAINER,
|
||||
ActionsBar,
|
||||
Banner,
|
||||
BodyViewModeSwitch,
|
||||
Button,
|
||||
CsvViewer,
|
||||
HexdumpViewer,
|
||||
Icon,
|
||||
ImageViewer,
|
||||
LARGE_BODY_BYTES,
|
||||
languageFromContentType,
|
||||
LoadingIcon,
|
||||
MultipartViewer,
|
||||
type PrettyView,
|
||||
prettyViewFor,
|
||||
type ResponseViewMode,
|
||||
SvgViewer,
|
||||
TextViewer,
|
||||
WebPageViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import { useAtom } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { bodyViewModeAtom } from "../lib/atoms";
|
||||
import {
|
||||
bodyAsText,
|
||||
bodyImageType,
|
||||
@@ -33,15 +43,97 @@ import { useSaveFlowBody } from "../lib/flowActions";
|
||||
interface Props {
|
||||
flow: Flow;
|
||||
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) {
|
||||
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 [mode, setMode] = useBodyViewMode(
|
||||
`${flow.id}.${which}`,
|
||||
(body.data?.bytes.length ?? 0) > LARGE_BODY_BYTES,
|
||||
);
|
||||
|
||||
if (body.isPending) {
|
||||
return (
|
||||
@@ -69,12 +161,28 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null;
|
||||
const text =
|
||||
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 ownsBar = false;
|
||||
if (decodeError != null) {
|
||||
// What failed to decode is as it was on the wire, compressed, so never text.
|
||||
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";
|
||||
viewer = (
|
||||
<MultipartViewer
|
||||
@@ -88,22 +196,43 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
viewer = <ImageBody bytes={bytes} mime={imageType} />;
|
||||
} else if (text == null) {
|
||||
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
||||
} else if (mime.startsWith("image/svg")) {
|
||||
viewer = <SvgViewer text={text} />;
|
||||
} else if (/csv|tab-separated/.test(mime) && pretty) {
|
||||
} else if (prettyView === "form" && pretty && form != null) {
|
||||
ownsBar = true;
|
||||
viewer = form(modeSwitch);
|
||||
} else if (prettyView === "table" && pretty) {
|
||||
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 {
|
||||
const language = languageFromContentType(contentType, text);
|
||||
if (language === "html" && pretty && which === "response") {
|
||||
viewer = <WebPageViewer html={text} baseUrl={flow.url} />;
|
||||
} else {
|
||||
viewer = (
|
||||
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} />
|
||||
);
|
||||
}
|
||||
ownsBar = true;
|
||||
viewer = (
|
||||
<FlowTextViewer
|
||||
flow={flow}
|
||||
which={which}
|
||||
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 (
|
||||
<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.
|
||||
</Banner>
|
||||
)}
|
||||
{truncated && (
|
||||
{truncated && !hideCutNote && (
|
||||
<Banner color="notice" className="text-sm mb-2">
|
||||
{wireTruncated || decodeError != null
|
||||
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
|
||||
@@ -168,7 +297,16 @@ function FlowTextViewer({
|
||||
text,
|
||||
language,
|
||||
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(
|
||||
(expression: string) => filterText(flow.id, which, expression, language),
|
||||
[flow.id, which, language],
|
||||
@@ -183,6 +321,8 @@ function FlowTextViewer({
|
||||
className="-mr-2 bg-surface"
|
||||
formatText={formatText}
|
||||
filterText={runFilter}
|
||||
ownActionsBar
|
||||
pinnedActions={modeSwitch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Dropdown,
|
||||
EmptyStateText,
|
||||
type ExtraPaneTab,
|
||||
FormUrlencodedEditor,
|
||||
type HttpBodyResult,
|
||||
HttpRequestPaneView,
|
||||
HttpResponsePaneView,
|
||||
@@ -13,14 +14,13 @@ import {
|
||||
IconButton,
|
||||
parseCookieHeaders,
|
||||
ResponseCookies,
|
||||
type ResponseViewMode,
|
||||
SplitLayout,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
|
||||
import { placeTabsAtom } from "../lib/atoms";
|
||||
import { findHeader } from "../lib/body";
|
||||
import {
|
||||
@@ -143,7 +143,6 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
||||
tab: {
|
||||
value: REQUEST_TAB_COOKIES,
|
||||
label: "Cookies",
|
||||
empty: sentCount === 0,
|
||||
rightSlot: <CountBadge count={sentCount} />,
|
||||
},
|
||||
content: <ResponseCookies response={cookies} side="sent" />,
|
||||
@@ -160,13 +159,16 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
||||
];
|
||||
const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available);
|
||||
|
||||
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
|
||||
|
||||
return (
|
||||
<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}
|
||||
fullHeight
|
||||
dimEmptyTabs
|
||||
forceUpdateKey={`${flow.updatedAt}.${body.data == null ? "" : "loaded"}`}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
tabsStorageKey="proxy_request_tabs"
|
||||
bodyLoader={loader}
|
||||
extraTabs={extraTabs}
|
||||
@@ -174,15 +176,30 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
urlLine={<UrlLine flow={flow} />}
|
||||
renderBody={(editor) => (
|
||||
renderBody={() => (
|
||||
<FlowRequestBody
|
||||
body={body}
|
||||
editor={editor}
|
||||
// The proxy's own viewer, with its hexdump and save, rather than a size alone
|
||||
binaryViewer={
|
||||
request.bodyType === BODY_TYPE_BINARY ? (
|
||||
<FlowBodyViewer flow={flow} which="request" pretty />
|
||||
) : null
|
||||
// The response's viewer choice, with the form's fields as a URL-encoded body's
|
||||
// formatted view, the switch in one bar with their bulk-view toggle
|
||||
viewer={
|
||||
<FlowBodyViewer
|
||||
flow={flow}
|
||||
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}
|
||||
/>
|
||||
@@ -210,7 +227,6 @@ function UrlLine({ flow }: { flow: Flow }) {
|
||||
}
|
||||
|
||||
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
|
||||
// message would only hold copies of it
|
||||
const loadBody = flow.streamKind == null;
|
||||
@@ -242,7 +258,6 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
|
||||
tab: {
|
||||
value: RESPONSE_TAB_MESSAGES,
|
||||
label: messagesLabel,
|
||||
empty: flow.messageCount === 0,
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={flow.messageCount}
|
||||
@@ -273,23 +288,18 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
|
||||
<HttpResponsePaneView
|
||||
bare
|
||||
requestShownApart
|
||||
dimEmptyTabs
|
||||
className={classNames(className, "border-border-subtle")}
|
||||
response={response}
|
||||
body={loader}
|
||||
requestBody={null}
|
||||
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
tabsStorageKey="proxy_response_tabs"
|
||||
activeTabKey={flow.id}
|
||||
defaultTab={defaultTab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
extraTabs={extraTabs}
|
||||
renderBodyViewer={() => (
|
||||
<FlowBodyViewer flow={flow} which="response" pretty={viewMode === "pretty"} />
|
||||
)}
|
||||
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
|
||||
statusLineCenter={
|
||||
flow.responseTruncated ? (
|
||||
<span
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
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 {
|
||||
appliedFilterAtom,
|
||||
@@ -28,6 +35,7 @@ import {
|
||||
type Column,
|
||||
type ColumnId,
|
||||
cellFilterTerm,
|
||||
columnShifts,
|
||||
customValue,
|
||||
gridTemplate,
|
||||
gridWidth,
|
||||
@@ -65,7 +73,7 @@ import {
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
import { FlowListEmpty, PausedRow } from "./FlowListEmpty";
|
||||
import { FlowListHeader } from "./FlowListHeader";
|
||||
import { type ColumnDragEvent, FlowListHeader } from "./FlowListHeader";
|
||||
|
||||
const BOTTOM_PADDING = 6;
|
||||
/** 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;
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent<HTMLDivElement>) => {
|
||||
@@ -439,10 +452,11 @@ export function FlowList({ style, className }: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
style={style}
|
||||
className={classNames(
|
||||
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 */}
|
||||
@@ -462,11 +476,14 @@ export function FlowList({ style, className }: Props) {
|
||||
onSort={handleSort}
|
||||
onSetSort={handleSetSort}
|
||||
onResetSort={follow}
|
||||
scrollElement={getScrollElement}
|
||||
onColumnDrag={onColumnDrag}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-flow-rows
|
||||
role="listbox"
|
||||
aria-label="Flows"
|
||||
tabIndex={0}
|
||||
@@ -576,6 +593,233 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
|
||||
}, [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 {
|
||||
flow: Flow;
|
||||
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 {
|
||||
ContextMenu,
|
||||
@@ -9,7 +17,16 @@ import {
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
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 {
|
||||
addSortKey,
|
||||
allColumns,
|
||||
@@ -19,9 +36,13 @@ import {
|
||||
type ColumnPrefs,
|
||||
type CustomColumnId,
|
||||
canHide,
|
||||
dropBoundary,
|
||||
hideOtherColumns,
|
||||
isCustomColumn,
|
||||
moveColumn,
|
||||
moveColumnBy,
|
||||
removeCustomColumn,
|
||||
resetOrder,
|
||||
resetWidth,
|
||||
resetWidths,
|
||||
resizeColumn,
|
||||
@@ -31,6 +52,13 @@ import {
|
||||
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
|
||||
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 {
|
||||
columns: Column[];
|
||||
template: string;
|
||||
@@ -43,13 +71,29 @@ interface Props {
|
||||
/** Sets the sort from the menus; empty goes back to arrival order */
|
||||
onSetSort: (sort: FlowSortKey[]) => 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
|
||||
* 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
|
||||
* right-click on a label opens the column's menu; the picker sits in the empty corner on hover.
|
||||
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
|
||||
* 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({
|
||||
columns,
|
||||
@@ -60,6 +104,8 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
onSort,
|
||||
onSetSort: setSort,
|
||||
onResetSort,
|
||||
scrollElement,
|
||||
onColumnDrag,
|
||||
}: Props) {
|
||||
const setPrefs = useSetAtom(columnPrefsAtom);
|
||||
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);
|
||||
// Widths when a drag began, so each move sets the width from where it started.
|
||||
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
|
||||
const removeColumn = (id: CustomColumnId) => {
|
||||
@@ -110,11 +162,17 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
{ type: "separator" },
|
||||
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
||||
{ 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 sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
|
||||
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
|
||||
const at = columns.findIndex((col) => col.id === c.id);
|
||||
return [
|
||||
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
|
||||
{ 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: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
|
||||
{ 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",
|
||||
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">
|
||||
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
|
||||
<div
|
||||
ref={gridRef}
|
||||
data-flow-header
|
||||
onPointerDownCapture={onPointerDownCapture}
|
||||
onContextMenu={openMenu}
|
||||
style={{ gridTemplateColumns: template }}
|
||||
className={classNames(
|
||||
@@ -182,60 +254,21 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
/>
|
||||
</Dropdown>
|
||||
</span>
|
||||
{columns.map((c) => {
|
||||
const at = sort.findIndex((k) => k.column === c.id);
|
||||
const key = sort[at];
|
||||
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
|
||||
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
|
||||
{columns.map((c) => (
|
||||
<HeaderCell
|
||||
key={c.id}
|
||||
data-column={c.id}
|
||||
className="relative h-full flex items-center min-w-0 px-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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(labelClass, "hover:text-text-subtle")}
|
||||
>
|
||||
{label}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
column={c}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
onResizeStart={() => {
|
||||
startWidth.current = c.current;
|
||||
}}
|
||||
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
|
||||
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
|
||||
/>
|
||||
))}
|
||||
</DndContext>
|
||||
<ContextMenu
|
||||
triggerPosition={menu}
|
||||
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
|
||||
@@ -245,3 +278,264 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
</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,
|
||||
});
|
||||
|
||||
function render(
|
||||
body: Parameters<typeof FlowRequestBody>[0]["body"],
|
||||
binaryViewer: string | null = null,
|
||||
) {
|
||||
function render(body: Parameters<typeof FlowRequestBody>[0]["body"]) {
|
||||
return renderToStaticMarkup(
|
||||
<FlowRequestBody
|
||||
body={body}
|
||||
editor={<div>EDITOR</div>}
|
||||
binaryViewer={binaryViewer == null ? null : <div>{binaryViewer}</div>}
|
||||
requestSize={2048}
|
||||
/>,
|
||||
<FlowRequestBody body={body} viewer={<div>EDITOR</div>} requestSize={2048} />,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,9 +41,8 @@ describe("FlowRequestBody", () => {
|
||||
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 }, "HEXDUMP")).toContain("HEXDUMP");
|
||||
});
|
||||
|
||||
test("a cut body says how much was kept", () => {
|
||||
|
||||
@@ -13,19 +13,17 @@ import type { FlowBodyData } from "../lib/flowViews";
|
||||
interface Props {
|
||||
/** The request body as the engine's body RPC returned it */
|
||||
body: HttpBodyResult<FlowBodyData | null>;
|
||||
/** The shared editor for the body's type */
|
||||
editor: ReactNode;
|
||||
/** The proxy's own viewer, for a body that isn't text; null for one that is */
|
||||
binaryViewer: ReactNode | null;
|
||||
/** The body in the viewer for its type, formatted or raw */
|
||||
viewer: ReactNode;
|
||||
/** Bytes of the request body on the wire */
|
||||
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.
|
||||
*/
|
||||
export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Props) {
|
||||
export function FlowRequestBody({ body, viewer, requestSize }: Props) {
|
||||
if (body.error != null) {
|
||||
return (
|
||||
<Banner color="danger" className="text-sm">
|
||||
@@ -40,7 +38,6 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
if (binaryViewer != null) return binaryViewer;
|
||||
if (body.data?.truncated) {
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
@@ -51,9 +48,9 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Banner>
|
||||
<div className="min-h-0">{editor}</div>
|
||||
<div className="min-h-0">{viewer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return editor;
|
||||
return viewer;
|
||||
}
|
||||
@@ -75,6 +75,38 @@ const graphqlRequest = JSON.stringify({
|
||||
operationName: "GetViewer",
|
||||
});
|
||||
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 =
|
||||
"<!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
|
||||
@@ -198,6 +230,55 @@ export const fixtures: FlowFixture[] = [
|
||||
requestBody: body(graphqlRequest),
|
||||
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",
|
||||
flow: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { atomWithStorage } from "jotai/utils";
|
||||
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 */
|
||||
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 {
|
||||
addCustomColumn,
|
||||
addSortKey,
|
||||
allColumns,
|
||||
cellFilterTerm,
|
||||
columnShifts,
|
||||
type ColumnPrefs,
|
||||
customPath,
|
||||
customPathProblem,
|
||||
customValue,
|
||||
defaultColumnPrefs,
|
||||
dropBoundary,
|
||||
fieldReference,
|
||||
fieldReferenceChanges,
|
||||
gridTemplate,
|
||||
gridWidth,
|
||||
hideOtherColumns,
|
||||
moveColumn,
|
||||
moveColumnBy,
|
||||
nextSort,
|
||||
operationOf,
|
||||
parseColumnPrefs,
|
||||
removeCustomColumn,
|
||||
resetOrder,
|
||||
resetWidth,
|
||||
resetWidths,
|
||||
resizeColumn,
|
||||
@@ -121,6 +127,7 @@ describe("column preferences", () => {
|
||||
visible: { number: false, "field:header.x-cache": true },
|
||||
widths: { host: 60 },
|
||||
custom: ["field:header.x-cache"],
|
||||
order: [],
|
||||
sort: [{ column: "status", descending: true }],
|
||||
});
|
||||
expect(
|
||||
@@ -138,6 +145,7 @@ describe("column preferences", () => {
|
||||
visible: {},
|
||||
widths: { size: 90 },
|
||||
custom: ["field:query.page"],
|
||||
order: [],
|
||||
sort: [{ column: "time", descending: false }],
|
||||
});
|
||||
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", () => {
|
||||
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
|
||||
* 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 {
|
||||
visible: Partial<Record<ColumnId, boolean>>;
|
||||
widths: Partial<Record<ColumnId, number>>;
|
||||
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 */
|
||||
sort: FlowSortKey[];
|
||||
}
|
||||
|
||||
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] };
|
||||
export const defaultColumnPrefs: ColumnPrefs = {
|
||||
visible: {},
|
||||
widths: {},
|
||||
custom: [],
|
||||
order: [],
|
||||
sort: [],
|
||||
};
|
||||
|
||||
export interface Column extends ColumnDef {
|
||||
/** Its width now, in pixels of content */
|
||||
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[] {
|
||||
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 {
|
||||
@@ -217,7 +236,104 @@ export function toggleColumn(
|
||||
): ColumnPrefs {
|
||||
const shown = isVisible(prefs, columnDef(id));
|
||||
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 */
|
||||
@@ -227,20 +343,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs
|
||||
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 {
|
||||
const id = customColumnId(path);
|
||||
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 {
|
||||
const { [id]: _v, ...visible } = prefs.visible;
|
||||
const { [id]: _w, ...widths } = prefs.widths;
|
||||
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);
|
||||
return { ...prefs, custom, visible, widths, sort };
|
||||
return { ...prefs, custom, visible, widths, order, sort };
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
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)) {
|
||||
for (const id of custom) {
|
||||
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];
|
||||
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)) {
|
||||
for (const key of sort) {
|
||||
if (key == null || typeof key !== "object") continue;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@tanstack/react-query": "^5.105.0",
|
||||
"@tanstack/react-virtual": "^3.14.24",
|
||||
"@tauri-apps/api": "^2.12.4",
|
||||
|
||||
Reference in new issue
Block a user