mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 04:44:12 +02:00
Own response filter state where the filter runs (#549)
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user