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 { useMemo, useState } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useResponseFilter } from "../../hooks/useResponseFilter";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { languageFromContentType } from "../../lib/contentType";
import { getContentTypeFromHeaders } from "../../lib/model_util";
@@ -52,30 +52,17 @@ interface HttpTextViewerProps {
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const filter = useResponseFilter({ stateKey: `response.body.${response.requestId}` });
const filteredBody = useResponseBodyText({ response, filter: filter.debouncedFilterText });
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
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 (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty}
className={className}
footerActions={[
@@ -98,7 +85,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
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 type { ReactNode } from "react";
import { Children, useCallback, useMemo } from "react";
import { createGlobalState } from "react-use";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { Children, useMemo } from "react";
import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import type { EditorProps } from "../core/Editor/Editor";
import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor";
@@ -16,56 +15,34 @@ interface Props {
text: string;
language: EditorProps["language"];
stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean;
className?: string;
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;
isPending: boolean;
error: boolean;
};
}
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({
language,
text,
stateKey,
filterStateKey,
pretty,
className,
footerActions,
onFilter,
filter,
filterResult,
}: Props) {
const filterKey = filterStateKey ?? stateKey;
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(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 canFilter =
filter != null && (language === "json" || language === "xml" || language === "html");
const isSearching = filter?.isSearching ?? false;
const filterText = filter?.filterText ?? null;
const resultError = filterResult?.error ?? false;
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
@@ -76,8 +53,8 @@ export function TextViewer({
nodes.push(
<div key="input" className="w-full opacity-100!">
<Input
key={filterKey ?? "filter"}
validate={!filteredResponse.error}
key={filter.stateKey ?? "filter"}
validate={!resultError}
hideLabel
autoFocus
containerClassName="bg-surface"
@@ -86,9 +63,9 @@ export function TextViewer({
label="Filter expression"
name="filter"
defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
onChange={setFilterText}
stateKey={filterKey ? `filter.${filterKey}` : null}
onKeyDown={(e) => e.key === "Escape" && filter.toggleSearch()}
onChange={filter.setFilterText}
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
/>
</div>,
);
@@ -98,10 +75,10 @@ export function TextViewer({
<IconButton
key="icon"
size="sm"
isLoading={filteredResponse.isPending}
isLoading={filterResult?.isPending ?? false}
icon={isSearching ? "x" : "filter"}
title={isSearching ? "Close filter" : "Filter response"}
onClick={toggleSearch}
onClick={filter.toggleSearch}
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
/>,
);
@@ -110,14 +87,12 @@ export function TextViewer({
}, [
canFilter,
footerActions,
filterKey,
filter,
filterText,
filteredResponse.error,
filteredResponse.isPending,
filterResult?.isPending,
resultError,
isSearching,
language,
setFilterText,
toggleSearch,
]);
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
@@ -126,11 +101,11 @@ export function TextViewer({
}
let body: string;
if (isSearching && filterText?.length > 0) {
if (filteredResponse.error) {
if (isSearching && filterText != null && filterText.length > 0) {
if (resultError) {
body = "";
} else {
body = filteredResponse.data != null ? filteredResponse.data : "";
body = filterResult?.data != null ? filterResult.data : "";
}
} else {
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,
};
}