diff --git a/apps/yaak-proxy/components/FlowBodyViewer.tsx b/apps/yaak-proxy/components/FlowBodyViewer.tsx index 2523880ac..8e687c3ab 100644 --- a/apps/yaak-proxy/components/FlowBodyViewer.tsx +++ b/apps/yaak-proxy/components/FlowBodyViewer.tsx @@ -43,8 +43,8 @@ import { useSaveFlowBody } from "../lib/flowActions"; interface Props { flow: Flow; which: FlowBody; - /** A URL-encoded body's fields, shown as its formatted view */ - form?: ReactNode; + /** 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; } @@ -178,7 +178,7 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props ); let viewer: ReactNode; - let textViewer = false; + let ownsBar = false; if (decodeError != null) { // What failed to decode is as it was on the wire, compressed, so never text. viewer = ; @@ -196,8 +196,9 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props viewer = ; } else if (text == null) { viewer = ; - } else if (prettyView === "form" && pretty) { - viewer = form; + } else if (prettyView === "form" && pretty && form != null) { + ownsBar = true; + viewer = form(modeSwitch); } else if (prettyView === "table" && pretty) { viewer = ; } else if (prettyView === "preview" && pretty) { @@ -207,7 +208,7 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props ); } else { - textViewer = true; + ownsBar = true; viewer = ( {viewer} - - {modeSwitch} - + {modeSwitch} ); } diff --git a/apps/yaak-proxy/components/FlowCard.tsx b/apps/yaak-proxy/components/FlowCard.tsx index 2fba503c7..16898f5e3 100644 --- a/apps/yaak-proxy/components/FlowCard.tsx +++ b/apps/yaak-proxy/components/FlowCard.tsx @@ -6,6 +6,7 @@ import { Dropdown, EmptyStateText, type ExtraPaneTab, + FormUrlencodedEditor, type HttpBodyResult, HttpRequestPaneView, HttpResponsePaneView, @@ -148,28 +149,44 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { ]; const { defaultTab, onTabChange } = useShapeTab("request", flow, available); + const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`; + return ( } - renderBody={(editor) => ( + renderBody={() => ( ( + + ) + : undefined + } hideCutNote /> } diff --git a/packages/ui/src/components/ActionsBar.tsx b/packages/ui/src/components/ActionsBar.tsx index 918a5476b..ff1315d61 100644 --- a/packages/ui/src/components/ActionsBar.tsx +++ b/packages/ui/src/components/ActionsBar.tsx @@ -7,9 +7,15 @@ export const ACTIONS_BAR_CONTAINER = "relative group/actions"; /** Set on an action's outermost element to keep it in view, as a control that shows state does */ export const PINNED_ACTION = { "data-pinned": true } as const; +/** + * Where every bar sits in its container, the same for every viewer: flush with the content's + * right edge, which a text viewer's scrollbar sits just past, and lifted off the bottom edge + * above its horizontal scrollbar. Not overridable, so bars line up from pane to pane. + */ +const ACTIONS_BAR_INSET = "bottom-2 right-0 left-0"; + interface Props { children: ReactNode; - className?: string; } /** @@ -17,14 +23,15 @@ interface Props { * small bordered buttons on the surface colour, so they read over text, each held to the height * and radius of an `sm` icon button and centred on the row. Each fades in while the * container is hovered or the action has focus, unless it carries `PINNED_ACTION`. The row lets - * clicks through to the content between its actions. + * clicks through to the content between its actions. Place the container over the whole tab + * content, not a viewer's inner box, so the bar's inset is the pane's. */ -export function ActionsBar({ children, className }: Props) { +export function ActionsBar({ children }: Props) { return (
void; /** Show the fields without a way to change them */ readOnly?: boolean; + /** Drawn in one bar with the bulk-edit toggle, like a view-mode switch */ + actions?: ReactNode; }; export function FormUrlencodedEditor({ @@ -19,6 +21,7 @@ export function FormUrlencodedEditor({ forceUpdateKey, onChange, readOnly, + actions, }: Props) { const pairs = useMemo( () => @@ -52,6 +55,7 @@ export function FormUrlencodedEditor({ pairs={pairs} onChange={handleChange} readOnly={readOnly} + actions={actions} forceUpdateKey={forceUpdateKey} stateKey={`urlencoded.${requestId}`} /> diff --git a/packages/ui/src/components/pairs/PairOrBulkEditor.test.tsx b/packages/ui/src/components/pairs/PairOrBulkEditor.test.tsx new file mode 100644 index 000000000..17d6134da --- /dev/null +++ b/packages/ui/src/components/pairs/PairOrBulkEditor.test.tsx @@ -0,0 +1,40 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test } from "vite-plus/test"; +import type { HostInputProps } from "../EditorHost"; +import { EditorHostProvider } from "../EditorHost"; +import { PairOrBulkEditor } from "./PairOrBulkEditor"; + +function StubInput({ name, defaultValue }: HostInputProps) { + return
{defaultValue}
; +} + +function render(actions?: string) { + return renderToStaticMarkup( + + {}} + actions={actions == null ? undefined : {actions}} + /> + , + ); +} + +describe("PairOrBulkEditor", () => { + test("with actions, its bulk toggle shares one actions bar with them", () => { + const markup = render("SWITCH"); + expect(markup).toContain("group/actions"); + // The toggle, then the given actions, inside the bar + expect(markup).toMatch(/group-hover\/actions[^>]*>.*Enable bulk edit.*SWITCH/); + }); + + test("without actions, its toggle keeps its own corner", () => { + const markup = render(); + expect(markup).not.toContain("group/actions"); + expect(markup).toContain("Enable bulk edit"); + }); +}); diff --git a/packages/ui/src/components/pairs/PairOrBulkEditor.tsx b/packages/ui/src/components/pairs/PairOrBulkEditor.tsx index 22f4facc4..7ac8fbea1 100644 --- a/packages/ui/src/components/pairs/PairOrBulkEditor.tsx +++ b/packages/ui/src/components/pairs/PairOrBulkEditor.tsx @@ -1,4 +1,6 @@ import classNames from "classnames"; +import type { ReactNode } from "react"; +import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar"; import { useEditorHost } from "../EditorHost"; import { IconButton } from "../IconButton"; import { BulkPairEditor } from "./BulkPairEditor"; @@ -8,9 +10,14 @@ import { PairEditor } from "./PairEditor"; interface Props extends PairEditorProps { preferenceName: string; forcedEnvironmentId?: string; + /** + * Drawn after the bulk-edit toggle in one `ActionsBar`, like a view-mode switch. Giving it puts + * the toggle in that bar rather than its own corner; once every site has moved, it always is + */ + actions?: ReactNode; } -export function PairOrBulkEditor({ preferenceName, ...props }: Props) { +export function PairOrBulkEditor({ preferenceName, actions, ...props }: Props) { const { useStoredValue } = useEditorHost(); const [useBulk, setUseBulk] = useStoredValue( ["bulk_edit", preferenceName], @@ -18,20 +25,37 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) { "global", ); + const editor = useBulk ? : ; + const toggleProps = { + size: "sm", + variant: "border", + title: useBulk ? "Enable form edit" : "Enable bulk edit", + onClick: () => setUseBulk((b) => !b), + icon: useBulk ? "table" : "file_code", + } as const; + + if (actions != null) { + return ( +
+ {editor} + + + {actions} + +
+ ); + } + return (
- {useBulk ? : } + {editor}
setUseBulk((b) => !b)} - icon={useBulk ? "table" : "file_code"} />