diff --git a/apps/yaak-client/components/HttpAuthenticationEditor.tsx b/apps/yaak-client/components/HttpAuthenticationEditor.tsx index 0da989bcc..159ceb16b 100644 --- a/apps/yaak-client/components/HttpAuthenticationEditor.tsx +++ b/apps/yaak-client/components/HttpAuthenticationEditor.tsx @@ -6,7 +6,7 @@ import type { Workspace, } 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 { openFolderSettings } from "../commands/openFolderSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; @@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton"; import { Input, type InputProps } from "./core/Input"; import { Link } from "./core/Link"; import { RadioDropdown } from "./core/RadioDropdown"; -import { SegmentedControl } from "./core/SegmentedControl"; import { DynamicForm } from "./DynamicForm"; import { EmptyStateText } from "./EmptyStateText"; diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 43e698e06..50d1f6e9e 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -5,7 +5,15 @@ import { type ImportSource, type Workspace, } 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 { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { errorMessage } from "../lib/errorMessage"; @@ -22,7 +30,6 @@ import { CheckboxTree } from "./core/CheckboxTree"; import { Chip } from "./core/Chip"; import { DialogFooter } from "./core/Dialog"; import { Select } from "./core/Select"; -import { SegmentedControl } from "./core/SegmentedControl"; import { pluralize } from "@yaakapp-internal/lib/pluralize"; interface Props { diff --git a/apps/yaak-client/components/MarkdownEditor.tsx b/apps/yaak-client/components/MarkdownEditor.tsx index be31570f2..80f540c55 100644 --- a/apps/yaak-client/components/MarkdownEditor.tsx +++ b/apps/yaak-client/components/MarkdownEditor.tsx @@ -1,8 +1,8 @@ +import { SegmentedControl } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useRef, useState } from "react"; import type { EditorProps } from "./core/Editor/Editor"; import { Editor } from "./core/Editor/LazyEditor"; -import { SegmentedControl } from "./core/SegmentedControl"; import { Markdown } from "./Markdown"; type ViewMode = "edit" | "preview"; diff --git a/apps/yaak-client/components/Settings/SettingsAgents.tsx b/apps/yaak-client/components/Settings/SettingsAgents.tsx index 4ba04091c..bdc0f629f 100644 --- a/apps/yaak-client/components/Settings/SettingsAgents.tsx +++ b/apps/yaak-client/components/Settings/SettingsAgents.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { platform } from "@yaakapp-internal/platform"; 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 { useKeyValue } from "../../hooks/useKeyValue"; import { usePluginInfos } from "../../hooks/usePluginInfo"; @@ -14,7 +14,6 @@ import { import { appInfo } from "../../lib/appInfo"; import { rpc } from "../../lib/rpc"; import { Link } from "../core/Link"; -import { SegmentedControl } from "../core/SegmentedControl"; import { SettingCommand, SettingSnippet, diff --git a/apps/yaak-proxy/components/FlowBodyViewer.test.tsx b/apps/yaak-proxy/components/FlowBodyViewer.test.tsx new file mode 100644 index 000000000..f7b9f63da --- /dev/null +++ b/apps/yaak-proxy/components/FlowBodyViewer.test.tsx @@ -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"); + }); +}); diff --git a/apps/yaak-proxy/components/FlowBodyViewer.tsx b/apps/yaak-proxy/components/FlowBodyViewer.tsx index e63850af9..656eb3673 100644 --- a/apps/yaak-proxy/components/FlowBodyViewer.tsx +++ b/apps/yaak-proxy/components/FlowBodyViewer.tsx @@ -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, shown as its formatted view */ + form?: 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(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 : ( + + ); let viewer: ReactNode; + let textViewer = false; if (decodeError != null) { // What failed to decode is as it was on the wire, compressed, so never text. viewer = ; - } else if (mime.startsWith("multipart/") && pretty) { + } else if (prettyView === "parts" && pretty) { const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown"; viewer = ( ; } else if (text == null) { viewer = ; - } else if (mime.startsWith("image/svg")) { - viewer = ; - } else if (/csv|tab-separated/.test(mime) && pretty) { + } else if (prettyView === "form" && pretty) { + viewer = form; + } else if (prettyView === "table" && pretty) { viewer = ; + } else if (prettyView === "preview" && pretty) { + viewer = mime.startsWith("image/svg") ? ( + + ) : ( + + ); } else { - const language = languageFromContentType(contentType, text); - if (language === "html" && pretty && which === "response") { - viewer = ; - } else { - viewer = ( - - ); - } + textViewer = true; + viewer = ( + + ); } - if (!truncated && decodeError == null) return viewer; + // The text viewer draws the switch in its own bar, beside its filter; the rest get a bar here + if (modeSwitch != null && !textViewer) { + viewer = ( +
+ {viewer} + {modeSwitch} +
+ ); + } + + if ((!truncated || hideCutNote) && decodeError == null) return viewer; return (
@@ -168,7 +295,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 +319,8 @@ function FlowTextViewer({ className="-mr-2 bg-surface" formatText={formatText} filterText={runFilter} + ownActionsBar + pinnedActions={modeSwitch} /> ); } diff --git a/apps/yaak-proxy/components/FlowCard.tsx b/apps/yaak-proxy/components/FlowCard.tsx index 6fd906c9c..78abde9bd 100644 --- a/apps/yaak-proxy/components/FlowCard.tsx +++ b/apps/yaak-proxy/components/FlowCard.tsx @@ -13,14 +13,13 @@ import { IconButton, parseCookieHeaders, ResponseCookies, - type ResponseViewMode, SplitLayout, Tooltip, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useSetAtom } 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 { findHeader } from "../lib/body"; import { browserTabMemory, @@ -166,12 +165,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { renderBody={(editor) => ( - ) : null + // The response's viewer choice, with the form's fields as a URL-encoded body's + // formatted view + viewer={ + } requestSize={flow.requestSize} /> @@ -199,7 +201,6 @@ function UrlLine({ flow }: { flow: Flow }) { } function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) { - const [viewMode, setViewMode] = useState("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; @@ -268,16 +269,12 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } body={loader} requestBody={null} emptyState={Waiting for a response} - viewMode={viewMode} - onViewModeChange={setViewMode} tabsStorageKey="proxy_response_tabs" activeTabKey={flow.id} defaultTab={defaultTab} onTabChange={onTabChange} extraTabs={extraTabs} - renderBodyViewer={() => ( - - )} + renderBodyViewer={() => } statusLineCenter={ flow.responseTruncated ? ( ({ storedSize: 7, }); -function render( - body: Parameters[0]["body"], - binaryViewer: string | null = null, -) { +function render(body: Parameters[0]["body"]) { return renderToStaticMarkup( - EDITOR
} - binaryViewer={binaryViewer == null ? null :
{binaryViewer}
} - requestSize={2048} - />, + EDITOR} 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", () => { diff --git a/apps/yaak-proxy/components/FlowRequestBody.tsx b/apps/yaak-proxy/components/FlowRequestBody.tsx index 162a0adcd..11d6cf69d 100644 --- a/apps/yaak-proxy/components/FlowRequestBody.tsx +++ b/apps/yaak-proxy/components/FlowRequestBody.tsx @@ -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; - /** 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 ( @@ -40,7 +38,6 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro ); } - if (binaryViewer != null) return binaryViewer; if (body.data?.truncated) { return (
@@ -51,9 +48,9 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro -
{editor}
+
{viewer}
); } - return editor; + return viewer; } diff --git a/apps/yaak-proxy/harness/fixtures.ts b/apps/yaak-proxy/harness/fixtures.ts index 7368f528c..0bca85476 100644 --- a/apps/yaak-proxy/harness/fixtures.ts +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -75,6 +75,27 @@ 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 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}--`, + "", +].join("\r\n"); +const textResponse = "Uploaded 2 parts"; const htmlResponse = "

Signed in

You can close this window.

"; // A 24x24 PNG gradient @@ -198,6 +219,41 @@ 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: "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: { diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts index 291ddef95..e68132615 100644 --- a/apps/yaak-proxy/lib/atoms.ts +++ b/apps/yaak-proxy/lib/atoms.ts @@ -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"; @@ -22,3 +22,11 @@ export const helperDialogOpenAtom = atom(false); /** Stacked by default; the header's toggle switches to side by side */ export const flowLayoutAtom = atomWithStorage("flow_layout", "vertical"); + +/** Bodies show formatted or raw, one choice for both halves of every flow */ +export const bodyViewModeAtom = atomWithStorage( + "body_view_mode", + "pretty", + undefined, + { getOnInit: true }, +); diff --git a/packages/ui/src/components/ActionsBar.tsx b/packages/ui/src/components/ActionsBar.tsx new file mode 100644 index 000000000..891d18395 --- /dev/null +++ b/packages/ui/src/components/ActionsBar.tsx @@ -0,0 +1,36 @@ +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; + +interface Props { + children: ReactNode; + className?: string; +} + +/** + * 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 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. + */ +export function ActionsBar({ children, className }: Props) { + return ( +
:not([data-pinned])]:opacity-0", + "group-hover/actions:*:opacity-100! *:focus-within:opacity-100! *:hover:opacity-100!", + )} + > + {children} +
+ ); +} diff --git a/packages/ui/src/components/FormatAction.test.tsx b/packages/ui/src/components/FormatAction.test.tsx new file mode 100644 index 000000000..c58ff4c11 --- /dev/null +++ b/packages/ui/src/components/FormatAction.test.tsx @@ -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()).toContain( + "Reformat contents", + ); + expect(renderToStaticMarkup()).toBe( + "", + ); + }); +}); diff --git a/packages/ui/src/components/FormatAction.tsx b/packages/ui/src/components/FormatAction.tsx new file mode 100644 index 000000000..b79f44498 --- /dev/null +++ b/packages/ui/src/components/FormatAction.tsx @@ -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; + +/** + * 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, +): Promise { + 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; + format: (text: string) => Promise; + /** 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 ( + { + if (editor.current == null) return; + await formatEditor(editor.current, format); + }} + /> + ); +} diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/packages/ui/src/components/SegmentedControl.tsx similarity index 73% rename from apps/yaak-client/components/core/SegmentedControl.tsx rename to packages/ui/src/components/SegmentedControl.tsx index b2ab147f5..74092c9f2 100644 --- a/apps/yaak-client/components/core/SegmentedControl.tsx +++ b/packages/ui/src/components/SegmentedControl.tsx @@ -1,19 +1,33 @@ -import type { IconProps } from "@yaakapp-internal/ui"; -import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui"; +import { generateId } from "@yaakapp-internal/lib/generateId"; import classNames from "classnames"; 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 { generateId } from "@yaakapp-internal/lib/generateId"; +import { IconTooltip } from "./IconTooltip"; +import { Label } from "./Label"; +import { HStack } from "./Stacks"; +import { Tooltip } from "./Tooltip"; -interface Props { - options: { value: T; label: string; icon?: IconProps["icon"] }[]; +export interface SegmentedControlOption { + 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 { + options: SegmentedControlOption[]; onChange: (value: T) => void; value: T; name: string; size?: IconButtonProps["size"]; label: string; className?: string; + /** Replaces the full width the control takes by default */ + containerClassName?: string; hideLabel?: boolean; labelClassName?: string; help?: ReactNode; @@ -29,7 +43,8 @@ export function SegmentedControl({ labelClassName, help, className, -}: Props) { + containerClassName = "w-full", +}: SegmentedControlProps) { const [selectedValue, setSelectedValue] = useStateWithDeps(value, [value]); const containerRef = useRef(null); const id = useRef(`input-${generateId()}`); @@ -38,8 +53,13 @@ export function SegmentedControl({ const inlineHelp = hideLabel && help ? : null; + const focusOption = (index: number) => { + const option = containerRef.current?.querySelectorAll('[role="radio"]')[index]; + option?.focus(); + }; + return ( -
+