mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 21:04:04 +02:00
Add response filter history with pinning (#338)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||||
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
|
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||||
import { useResponseFilter } from "../../hooks/useResponseFilter";
|
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";
|
||||||
@@ -52,8 +54,16 @@ interface HttpTextViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||||
const filter = useResponseFilter({ stateKey: `response.body.${response.requestId}` });
|
const queryClient = useQueryClient();
|
||||||
const filteredBody = useResponseBodyText({ response, filter: filter.debouncedFilterText });
|
const filter = useResponseFilter({
|
||||||
|
stateKey: `response.body.${response.requestId}`,
|
||||||
|
// Shares the display query's cache entry, so the verdict costs no extra RPC
|
||||||
|
runFilter: useCallback(
|
||||||
|
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
||||||
|
[queryClient, response],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
|
||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
import type { RecentFilter } from "../../hooks/useRecentFilters";
|
||||||
|
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
||||||
|
import { IconButton } from "../core/IconButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recentFilters: RecentFilter[];
|
||||||
|
activeFilter: string | null;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
onTogglePin: (value: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentFiltersDropdown({
|
||||||
|
recentFilters,
|
||||||
|
activeFilter,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onTogglePin,
|
||||||
|
onClear,
|
||||||
|
}: Props) {
|
||||||
|
const pinned = recentFilters.filter((f) => f.pinned);
|
||||||
|
const unpinned = recentFilters.filter((f) => !f.pinned);
|
||||||
|
|
||||||
|
const toItem = (filter: RecentFilter): DropdownItem => ({
|
||||||
|
label: (
|
||||||
|
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
|
||||||
|
{filter.value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
|
||||||
|
onSelect: () => onSelect(filter.value),
|
||||||
|
submenuTrigger: "button",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: filter.pinned ? "Unpin" : "Pin",
|
||||||
|
icon: filter.pinned ? "unpin" : "pin",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => onTogglePin(filter.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Remove",
|
||||||
|
icon: "trash",
|
||||||
|
color: "danger",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => onRemove(filter.value),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: DropdownItem[] = [];
|
||||||
|
|
||||||
|
if (recentFilters.length === 0) {
|
||||||
|
items.push({
|
||||||
|
type: "content",
|
||||||
|
label: (
|
||||||
|
<span className="block px-4 py-1 text-sm text-text-subtle">
|
||||||
|
Filters you use are remembered here
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinned.length > 0) {
|
||||||
|
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unpinned.length > 0) {
|
||||||
|
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentFilters.length > 0) {
|
||||||
|
items.push(
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "Clear All",
|
||||||
|
leftSlot: <Icon icon="trash" />,
|
||||||
|
color: "danger",
|
||||||
|
onSelect: onClear,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown items={items}>
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="filter"
|
||||||
|
title="Recent filters"
|
||||||
|
iconColor="secondary"
|
||||||
|
className="w-8 ml-0.5 mr-1 h-auto!"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Children, useMemo } from "react";
|
import { Children, useCallback, useMemo } from "react";
|
||||||
|
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { useFormatText } from "../../hooks/useFormatText";
|
||||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||||
|
import { Button } from "../core/Button";
|
||||||
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";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
|
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||||
|
|
||||||
const extraExtensions = [hyperlink];
|
const extraExtensions = [hyperlink];
|
||||||
|
|
||||||
@@ -18,9 +20,7 @@ interface Props {
|
|||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
footerActions?: ReactNode;
|
footerActions?: ReactNode;
|
||||||
/** Filter state, from useResponseFilter in whichever component runs the filter */
|
|
||||||
filter?: ResponseFilterApi;
|
filter?: ResponseFilterApi;
|
||||||
/** Result of applying `filter.debouncedFilterText` to the body */
|
|
||||||
filterResult?: {
|
filterResult?: {
|
||||||
data: string | null | undefined;
|
data: string | null | undefined;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
@@ -41,9 +41,21 @@ export function TextViewer({
|
|||||||
const canFilter =
|
const canFilter =
|
||||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||||
const isSearching = filter?.isSearching ?? false;
|
const isSearching = filter?.isSearching ?? false;
|
||||||
const filterText = filter?.filterText ?? null;
|
const appliedFilter = filter?.appliedFilter ?? null;
|
||||||
const resultError = filterResult?.error ?? false;
|
const resultError = filterResult?.error ?? false;
|
||||||
|
|
||||||
|
const handleFilterKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (filter == null) return;
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
filter.toggleSearch();
|
||||||
|
} else if (e.key === "Enter" && filter.filterText != null) {
|
||||||
|
filter.applyFilter(filter.filterText);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filter],
|
||||||
|
);
|
||||||
|
|
||||||
const actions = useMemo<ReactNode[]>(() => {
|
const actions = useMemo<ReactNode[]>(() => {
|
||||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||||
|
|
||||||
@@ -62,37 +74,62 @@ export function TextViewer({
|
|||||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
||||||
label="Filter expression"
|
label="Filter expression"
|
||||||
name="filter"
|
name="filter"
|
||||||
defaultValue={filterText}
|
defaultValue={filter.filterText}
|
||||||
onKeyDown={(e) => e.key === "Escape" && filter.toggleSearch()}
|
forceUpdateKey={filter.filterUpdateKey}
|
||||||
|
onKeyDown={handleFilterKeyDown}
|
||||||
onChange={filter.setFilterText}
|
onChange={filter.setFilterText}
|
||||||
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
||||||
|
leftSlot={
|
||||||
|
<div className="py-0.5 flex">
|
||||||
|
<RecentFiltersDropdown
|
||||||
|
recentFilters={filter.recentFilters}
|
||||||
|
activeFilter={filter.appliedFilter}
|
||||||
|
onSelect={filter.replaceFilter}
|
||||||
|
onRemove={filter.removeRecentFilter}
|
||||||
|
onTogglePin={filter.togglePinRecentFilter}
|
||||||
|
onClear={filter.clearRecentFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rightSlot={
|
||||||
|
<div className="py-0.5 flex">
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="x"
|
||||||
|
title="Close filter"
|
||||||
|
iconColor="secondary"
|
||||||
|
onClick={filter.toggleSearch}
|
||||||
|
className="w-8 mr-0.5 h-auto!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
nodes.push(
|
||||||
|
<IconButton
|
||||||
|
key="icon"
|
||||||
|
size="sm"
|
||||||
|
isLoading={filterResult?.isPending ?? false}
|
||||||
|
icon="filter"
|
||||||
|
title="Filter response"
|
||||||
|
onClick={filter.toggleSearch}
|
||||||
|
className="border border-border-subtle!"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes.push(
|
|
||||||
<IconButton
|
|
||||||
key="icon"
|
|
||||||
size="sm"
|
|
||||||
isLoading={filterResult?.isPending ?? false}
|
|
||||||
icon={isSearching ? "x" : "filter"}
|
|
||||||
title={isSearching ? "Close filter" : "Filter response"}
|
|
||||||
onClick={filter.toggleSearch}
|
|
||||||
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}, [
|
}, [
|
||||||
canFilter,
|
canFilter,
|
||||||
footerActions,
|
footerActions,
|
||||||
filter,
|
filter,
|
||||||
filterText,
|
|
||||||
filterResult?.isPending,
|
filterResult?.isPending,
|
||||||
resultError,
|
resultError,
|
||||||
isSearching,
|
isSearching,
|
||||||
language,
|
language,
|
||||||
|
handleFilterKeyDown,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||||
@@ -101,7 +138,7 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body: string;
|
let body: string;
|
||||||
if (isSearching && filterText != null && filterText.length > 0) {
|
if (appliedFilter) {
|
||||||
if (resultError) {
|
if (resultError) {
|
||||||
body = "";
|
body = "";
|
||||||
} else {
|
} else {
|
||||||
@@ -118,15 +155,61 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Editor
|
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||||
readOnly
|
{appliedFilter && filter != null ? (
|
||||||
className={className}
|
<AppliedFilterBar
|
||||||
defaultValue={body}
|
filter={appliedFilter}
|
||||||
language={language}
|
error={resultError}
|
||||||
actions={actions}
|
onClear={() => filter.replaceFilter("")}
|
||||||
extraExtensions={extraExtensions}
|
/>
|
||||||
stateKey={stateKey}
|
) : (
|
||||||
/>
|
<span />
|
||||||
|
)}
|
||||||
|
<Editor
|
||||||
|
readOnly
|
||||||
|
className={className}
|
||||||
|
defaultValue={body}
|
||||||
|
language={language}
|
||||||
|
actions={actions}
|
||||||
|
extraExtensions={extraExtensions}
|
||||||
|
stateKey={stateKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows what's actually filtering the body, which the filter box below can't convey
|
||||||
|
* once it holds an edited expression that hasn't been applied yet.
|
||||||
|
*/
|
||||||
|
function AppliedFilterBar({
|
||||||
|
filter,
|
||||||
|
error,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
filter: string;
|
||||||
|
error: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
||||||
|
<HStack space={2} className="min-w-0">
|
||||||
|
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
||||||
|
<span className="truncate min-w-0" title={filter}>
|
||||||
|
Response filtered by <InlineCode>{filter}</InlineCode>
|
||||||
|
{error && " (invalid expression)"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color={error ? "danger" : "info"}
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useKeyValue } from "./useKeyValue";
|
||||||
|
|
||||||
|
export interface RecentFilter {
|
||||||
|
value: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RECENT_FILTERS = 20;
|
||||||
|
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
|
||||||
|
const namespace = "global";
|
||||||
|
const fallback: RecentFilter[] = [];
|
||||||
|
|
||||||
|
export function useRecentFilters(filterStateKey: string | null) {
|
||||||
|
const { value, set } = useKeyValue<RecentFilter[]>({
|
||||||
|
key: kvKey(filterStateKey ?? "n/a"),
|
||||||
|
namespace,
|
||||||
|
fallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addFilter = useCallback(
|
||||||
|
async (rawValue: string) => {
|
||||||
|
const value = rawValue.trim();
|
||||||
|
if (filterStateKey == null || value === "") return;
|
||||||
|
await set((prev) => {
|
||||||
|
// Returning the same reference skips the write, so re-committing the
|
||||||
|
// expression already at the top (on every blur) costs nothing
|
||||||
|
if (prev[0]?.value === value) return prev;
|
||||||
|
const existing = prev.find((f) => f.value === value);
|
||||||
|
const rest = prev.filter((f) => f.value !== value);
|
||||||
|
return trim([{ value, pinned: existing?.pinned }, ...rest]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[filterStateKey, set],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFilter = useCallback(
|
||||||
|
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
|
||||||
|
[set],
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePin = useCallback(
|
||||||
|
async (value: string) =>
|
||||||
|
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
|
||||||
|
[set],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(async () => set([]), [set]);
|
||||||
|
|
||||||
|
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
|
||||||
|
function trim(filters: RecentFilter[]): RecentFilter[] {
|
||||||
|
const excess = filters.length - MAX_RECENT_FILTERS;
|
||||||
|
if (excess <= 0) return filters;
|
||||||
|
|
||||||
|
const evicted = new Set<number>();
|
||||||
|
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
||||||
|
if (!filters[i]?.pinned) evicted.add(i);
|
||||||
|
}
|
||||||
|
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
||||||
|
evicted.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters.filter((_, i) => !evicted.has(i));
|
||||||
|
}
|
||||||
@@ -2,6 +2,25 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
|
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
|
||||||
|
|
||||||
|
export function responseBodyTextQuery({
|
||||||
|
response,
|
||||||
|
filter,
|
||||||
|
}: {
|
||||||
|
response: HttpResponse;
|
||||||
|
filter: string | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
queryKey: [
|
||||||
|
"response_body_text",
|
||||||
|
response.id,
|
||||||
|
response.updatedAt,
|
||||||
|
response.contentLength,
|
||||||
|
filter ?? "",
|
||||||
|
],
|
||||||
|
queryFn: () => getResponseBodyText({ response, filter }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useResponseBodyText({
|
export function useResponseBodyText({
|
||||||
response,
|
response,
|
||||||
filter,
|
filter,
|
||||||
@@ -11,14 +30,7 @@ export function useResponseBodyText({
|
|||||||
}) {
|
}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||||
queryKey: [
|
...responseBodyTextQuery({ response, filter }),
|
||||||
"response_body_text",
|
|
||||||
response.id,
|
|
||||||
response.updatedAt,
|
|
||||||
response.contentLength,
|
|
||||||
filter ?? "",
|
|
||||||
],
|
|
||||||
queryFn: () => getResponseBodyText({ response, filter }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,57 @@
|
|||||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
import { useCallback, useState } from "react";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { createGlobalState } from "react-use";
|
import { createGlobalState } from "react-use";
|
||||||
|
import type { RecentFilter } from "./useRecentFilters";
|
||||||
|
import { useRecentFilters } from "./useRecentFilters";
|
||||||
|
|
||||||
/** What's typed in the filter box. `null` means the filter box is closed */
|
/** What's typed in the filter box. `null` means the filter box is closed */
|
||||||
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
|
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
|
||||||
|
|
||||||
|
/** What's actually applied to the response. Only changes on an explicit apply */
|
||||||
|
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
|
||||||
|
|
||||||
export interface ResponseFilterApi {
|
export interface ResponseFilterApi {
|
||||||
stateKey: string | null;
|
stateKey: string | null;
|
||||||
/** What's typed in the filter box, or `null` when the box is closed */
|
/** Draft text in the filter box, or `null` when the box is closed */
|
||||||
filterText: string | null;
|
filterText: string | null;
|
||||||
/** The expression to actually filter with, lagging `filterText` by a debounce */
|
/** The expression currently filtering the response */
|
||||||
debouncedFilterText: string | null;
|
appliedFilter: string | null;
|
||||||
isSearching: boolean;
|
isSearching: boolean;
|
||||||
|
/** The box holds an expression that isn't the one currently applied */
|
||||||
|
isDirty: boolean;
|
||||||
|
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
|
||||||
|
filterUpdateKey: number;
|
||||||
setFilterText: (value: string | null) => void;
|
setFilterText: (value: string | null) => void;
|
||||||
|
/** Apply the expression to the response, recording it if the filter accepts it */
|
||||||
|
applyFilter: (value: string) => void;
|
||||||
|
/** Like applyFilter, but also replaces what's shown in the filter box */
|
||||||
|
replaceFilter: (value: string) => void;
|
||||||
toggleSearch: () => void;
|
toggleSearch: () => void;
|
||||||
|
recentFilters: RecentFilter[];
|
||||||
|
removeRecentFilter: (value: string) => void;
|
||||||
|
togglePinRecentFilter: (value: string) => void;
|
||||||
|
clearRecentFilters: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter state for a response viewer, keyed so it persists across responses of the
|
* Draft/applied state and history for a response filter (JSONPath/XPath).
|
||||||
* same request.
|
|
||||||
*
|
*
|
||||||
* Owned by the component that runs the filter, so the viewer can stay presentational.
|
* History records at most one entry per apply gesture, and only after `runFilter`
|
||||||
* Evaluating the expression during the viewer's render (its previous shape) meant
|
* confirms the plugin accepts the expression — the plugin is the sole judge of
|
||||||
* updating the parent mid-render, which React warns about.
|
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
|
||||||
|
* deleted entries and a gesture can't record into another request's history.
|
||||||
*/
|
*/
|
||||||
export function useResponseFilter({ stateKey }: { stateKey: string | null }): ResponseFilterApi {
|
export function useResponseFilter({
|
||||||
|
stateKey,
|
||||||
|
runFilter,
|
||||||
|
}: {
|
||||||
|
stateKey: string | null;
|
||||||
|
/** Evaluate an expression, rejecting if the filter plugin reports an error */
|
||||||
|
runFilter: (filter: string) => Promise<unknown>;
|
||||||
|
}): ResponseFilterApi {
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
|
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
|
||||||
|
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
|
||||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
||||||
const debouncedFilterText = useDebouncedValue(filterText);
|
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
|
||||||
|
|
||||||
const setFilterText = useCallback(
|
const setFilterText = useCallback(
|
||||||
(v: string | null) => {
|
(v: string | null) => {
|
||||||
@@ -37,17 +61,69 @@ export function useResponseFilter({ stateKey }: { stateKey: string | null }): Re
|
|||||||
[stateKey, setFilterTextMap],
|
[stateKey, setFilterTextMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setAppliedFilter = useCallback(
|
||||||
|
(v: string | null) => {
|
||||||
|
if (!stateKey) return;
|
||||||
|
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
|
||||||
|
},
|
||||||
|
[stateKey, setAppliedFilterMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
recentFilters,
|
||||||
|
addFilter,
|
||||||
|
removeFilter: removeRecentFilter,
|
||||||
|
togglePin: togglePinRecentFilter,
|
||||||
|
clearFilters: clearRecentFilters,
|
||||||
|
} = useRecentFilters(stateKey);
|
||||||
|
|
||||||
|
const applyFilter = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setFilterText(value);
|
||||||
|
const applied = value.trim() === "" ? null : value.trim();
|
||||||
|
setAppliedFilter(applied);
|
||||||
|
if (applied == null) return;
|
||||||
|
runFilter(applied).then(
|
||||||
|
() => addFilter(applied),
|
||||||
|
() => {}, // Rejected by the filter plugin — don't record
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setFilterText, setAppliedFilter, runFilter, addFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
||||||
|
const replaceFilter = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
applyFilter(value);
|
||||||
|
setFilterUpdateKey((k) => k + 1);
|
||||||
|
},
|
||||||
|
[applyFilter],
|
||||||
|
);
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
const isSearching = filterText != null;
|
||||||
const toggleSearch = useCallback(() => {
|
const toggleSearch = useCallback(() => {
|
||||||
setFilterText(isSearching ? null : "");
|
if (isSearching) {
|
||||||
}, [isSearching, setFilterText]);
|
setFilterText(null);
|
||||||
|
setAppliedFilter(null);
|
||||||
|
} else {
|
||||||
|
setFilterText("");
|
||||||
|
}
|
||||||
|
}, [isSearching, setFilterText, setAppliedFilter]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stateKey,
|
stateKey,
|
||||||
filterText,
|
filterText,
|
||||||
debouncedFilterText,
|
appliedFilter,
|
||||||
isSearching,
|
isSearching,
|
||||||
|
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
|
||||||
|
filterUpdateKey,
|
||||||
setFilterText,
|
setFilterText,
|
||||||
|
applyFilter,
|
||||||
|
replaceFilter,
|
||||||
toggleSearch,
|
toggleSearch,
|
||||||
|
recentFilters,
|
||||||
|
removeRecentFilter,
|
||||||
|
togglePinRecentFilter,
|
||||||
|
clearRecentFilters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user