Own response filter state where the filter runs (#549)

This commit is contained in:
Gregory Schier
2026-08-14 22:38:38 -07:00
committed by GitHub
parent 23e7229e63
commit 7dfa7e07e3
3 changed files with 88 additions and 68 deletions
@@ -1,7 +1,7 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useState } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse"; import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useResponseBodyText } from "../../hooks/useResponseBodyText"; import { useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useResponseFilter } from "../../hooks/useResponseFilter";
import { useSaveResponse } from "../../hooks/useSaveResponse"; import { useSaveResponse } from "../../hooks/useSaveResponse";
import { languageFromContentType } from "../../lib/contentType"; import { languageFromContentType } from "../../lib/contentType";
import { getContentTypeFromHeaders } from "../../lib/model_util"; import { getContentTypeFromHeaders } from "../../lib/model_util";
@@ -52,30 +52,17 @@ interface HttpTextViewerProps {
} }
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) { function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null); const filter = useResponseFilter({ stateKey: `response.body.${response.requestId}` });
const filteredBody = useResponseBodyText({ response, filter: currentFilter }); const filteredBody = useResponseBodyText({ response, filter: filter.debouncedFilterText });
const saveResponse = useSaveResponse(response); const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response); const copyResponse = useCopyHttpResponse(response);
const actionsDisabled = response.state !== "closed" && response.status >= 100; const actionsDisabled = response.state !== "closed" && response.status >= 100;
const filterCallback = useMemo(
() => (filter: string) => {
setCurrentFilter(filter);
return {
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
};
},
[filteredBody],
);
return ( return (
<TextViewer <TextViewer
text={text} text={text}
language={language} language={language}
stateKey={`response.body.${response.id}`} stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty} pretty={pretty}
className={className} className={className}
footerActions={[ footerActions={[
@@ -98,7 +85,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
className="border !border-border-subtle" className="border !border-border-subtle"
/>, />,
]} ]}
onFilter={filterCallback} filter={filter}
filterResult={{
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
}}
/> />
); );
} }
@@ -1,9 +1,8 @@
import classNames from "classnames"; import classNames from "classnames";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Children, useCallback, useMemo } from "react"; import { Children, useMemo } from "react";
import { createGlobalState } from "react-use";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { useFormatText } from "../../hooks/useFormatText"; import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import type { EditorProps } from "../core/Editor/Editor"; import type { EditorProps } from "../core/Editor/Editor";
import { hyperlink } from "../core/Editor/hyperlink/extension"; import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor"; import { Editor } from "../core/Editor/LazyEditor";
@@ -16,56 +15,34 @@ interface Props {
text: string; text: string;
language: EditorProps["language"]; language: EditorProps["language"];
stateKey: string | null; stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean; pretty?: boolean;
className?: string; className?: string;
footerActions?: ReactNode; footerActions?: ReactNode;
onFilter?: (filter: string) => { /** Filter state, from useResponseFilter in whichever component runs the filter */
filter?: ResponseFilterApi;
/** Result of applying `filter.debouncedFilterText` to the body */
filterResult?: {
data: string | null | undefined; data: string | null | undefined;
isPending: boolean; isPending: boolean;
error: boolean; error: boolean;
}; };
} }
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ export function TextViewer({
language, language,
text, text,
stateKey, stateKey,
filterStateKey,
pretty, pretty,
className, className,
footerActions, footerActions,
onFilter, filter,
filterResult,
}: Props) { }: Props) {
const filterKey = filterStateKey ?? stateKey; const canFilter =
const [filterTextMap, setFilterTextMap] = useFilterText(); filter != null && (language === "json" || language === "xml" || language === "html");
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null; const isSearching = filter?.isSearching ?? false;
const debouncedFilterText = useDebouncedValue(filterText); const filterText = filter?.filterText ?? null;
const setFilterText = useCallback( const resultError = filterResult?.error ?? false;
(v: string | null) => {
if (!filterKey) return;
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
},
[filterKey, setFilterTextMap],
);
const isSearching = filterText != null;
const filteredResponse =
onFilter && debouncedFilterText
? onFilter(debouncedFilterText)
: { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText]);
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
const actions = useMemo<ReactNode[]>(() => { const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
@@ -76,8 +53,8 @@ export function TextViewer({
nodes.push( nodes.push(
<div key="input" className="w-full opacity-100!"> <div key="input" className="w-full opacity-100!">
<Input <Input
key={filterKey ?? "filter"} key={filter.stateKey ?? "filter"}
validate={!filteredResponse.error} validate={!resultError}
hideLabel hideLabel
autoFocus autoFocus
containerClassName="bg-surface" containerClassName="bg-surface"
@@ -86,9 +63,9 @@ export function TextViewer({
label="Filter expression" label="Filter expression"
name="filter" name="filter"
defaultValue={filterText} defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()} onKeyDown={(e) => e.key === "Escape" && filter.toggleSearch()}
onChange={setFilterText} onChange={filter.setFilterText}
stateKey={filterKey ? `filter.${filterKey}` : null} stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
/> />
</div>, </div>,
); );
@@ -98,10 +75,10 @@ export function TextViewer({
<IconButton <IconButton
key="icon" key="icon"
size="sm" size="sm"
isLoading={filteredResponse.isPending} isLoading={filterResult?.isPending ?? false}
icon={isSearching ? "x" : "filter"} icon={isSearching ? "x" : "filter"}
title={isSearching ? "Close filter" : "Filter response"} title={isSearching ? "Close filter" : "Filter response"}
onClick={toggleSearch} onClick={filter.toggleSearch}
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")} className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
/>, />,
); );
@@ -110,14 +87,12 @@ export function TextViewer({
}, [ }, [
canFilter, canFilter,
footerActions, footerActions,
filterKey, filter,
filterText, filterText,
filteredResponse.error, filterResult?.isPending,
filteredResponse.isPending, resultError,
isSearching, isSearching,
language, language,
setFilterText,
toggleSearch,
]); ]);
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false }); const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
@@ -126,11 +101,11 @@ export function TextViewer({
} }
let body: string; let body: string;
if (isSearching && filterText?.length > 0) { if (isSearching && filterText != null && filterText.length > 0) {
if (filteredResponse.error) { if (resultError) {
body = ""; body = "";
} else { } else {
body = filteredResponse.data != null ? filteredResponse.data : ""; body = filterResult?.data != null ? filterResult.data : "";
} }
} else { } else {
body = formattedBody; body = formattedBody;
@@ -0,0 +1,53 @@
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { createGlobalState } from "react-use";
/** What's typed in the filter box. `null` means the filter box is closed */
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
export interface ResponseFilterApi {
stateKey: string | null;
/** What's typed in the filter box, or `null` when the box is closed */
filterText: string | null;
/** The expression to actually filter with, lagging `filterText` by a debounce */
debouncedFilterText: string | null;
isSearching: boolean;
setFilterText: (value: string | null) => void;
toggleSearch: () => void;
}
/**
* Filter state for a response viewer, keyed so it persists across responses of the
* same request.
*
* Owned by the component that runs the filter, so the viewer can stay presentational.
* Evaluating the expression during the viewer's render (its previous shape) meant
* updating the parent mid-render, which React warns about.
*/
export function useResponseFilter({ stateKey }: { stateKey: string | null }): ResponseFilterApi {
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setFilterTextMap],
);
const isSearching = filterText != null;
const toggleSearch = useCallback(() => {
setFilterText(isSearching ? null : "");
}, [isSearching, setFilterText]);
return {
stateKey,
filterText,
debouncedFilterText,
isSearching,
setFilterText,
toggleSearch,
};
}