feat(keywords): add search intent filter to Keyword Research (#76)
Add a multi-select intent filter (Informational, Commercial, Transactional, Navigational, Unknown) to the Keyword Research table on both the desktop and mobile layouts (closes #67). Selected intents are stored as a comma-separated string in a new `intents` filter field, so they fit the existing all-strings filter shape and get active-filter counting, persistence, and clear-all for free. Sorting, pagination, save, and export already derive from the filtered row set, so they respect the intent filter automatically. The persistence schema defaults `intents` to "" so filter blobs saved before this change still load. Intent classification and URL params are untouched. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65f7baf8a1
commit
927e931e51
@ -18,32 +18,41 @@ const SHORT_LABELS: Record<KeywordIntent, string> = {
|
|||||||
unknown: "?",
|
unknown: "?",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Full intent labels, shared with the keyword filters so both stay in sync. */
|
||||||
|
export const INTENT_LABELS: Record<KeywordIntent, string> = {
|
||||||
|
informational: "Informational",
|
||||||
|
commercial: "Commercial",
|
||||||
|
transactional: "Transactional",
|
||||||
|
navigational: "Navigational",
|
||||||
|
unknown: "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
const DESCRIPTIONS: Record<
|
const DESCRIPTIONS: Record<
|
||||||
KeywordIntent,
|
KeywordIntent,
|
||||||
{ label: string; description: string }
|
{ label: string; description: string }
|
||||||
> = {
|
> = {
|
||||||
informational: {
|
informational: {
|
||||||
label: "Informational",
|
label: INTENT_LABELS.informational,
|
||||||
description:
|
description:
|
||||||
"The searcher wants information or answers. Use this for educational content, guides, and comparison-light explainers.",
|
"The searcher wants information or answers. Use this for educational content, guides, and comparison-light explainers.",
|
||||||
},
|
},
|
||||||
commercial: {
|
commercial: {
|
||||||
label: "Commercial",
|
label: INTENT_LABELS.commercial,
|
||||||
description:
|
description:
|
||||||
"The searcher is researching options before a purchase. Treat this as buying intent for comparisons, alternatives, and product-led pages.",
|
"The searcher is researching options before a purchase. Treat this as buying intent for comparisons, alternatives, and product-led pages.",
|
||||||
},
|
},
|
||||||
transactional: {
|
transactional: {
|
||||||
label: "Transactional",
|
label: INTENT_LABELS.transactional,
|
||||||
description:
|
description:
|
||||||
"The searcher is ready to complete an action, often a purchase. Prioritize clear offers, pricing, trials, or conversion paths.",
|
"The searcher is ready to complete an action, often a purchase. Prioritize clear offers, pricing, trials, or conversion paths.",
|
||||||
},
|
},
|
||||||
navigational: {
|
navigational: {
|
||||||
label: "Navigational",
|
label: INTENT_LABELS.navigational,
|
||||||
description:
|
description:
|
||||||
"The searcher is looking for a specific site, brand, or page. These queries usually reward matching the expected destination.",
|
"The searcher is looking for a specific site, brand, or page. These queries usually reward matching the expected destination.",
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
label: "Unknown",
|
label: INTENT_LABELS.unknown,
|
||||||
description:
|
description:
|
||||||
"Intent was not available for this keyword, so avoid making content strategy decisions from this badge alone.",
|
"Intent was not available for this keyword, so avoid making content strategy decisions from this badge alone.",
|
||||||
},
|
},
|
||||||
|
|||||||
107
src/client/features/keywords/hooks/useKeywordFiltering.test.ts
Normal file
107
src/client/features/keywords/hooks/useKeywordFiltering.test.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { KeywordIntent, KeywordResearchRow } from "@/types/keywords";
|
||||||
|
import {
|
||||||
|
EMPTY_FILTERS,
|
||||||
|
parseIntentFilter,
|
||||||
|
toggleIntentFilter,
|
||||||
|
type KeywordFilterValues,
|
||||||
|
} from "@/client/features/keywords/keywordResearchTypes";
|
||||||
|
import { applyKeywordFiltersAndSort } from "./useKeywordFiltering";
|
||||||
|
|
||||||
|
function makeRow(keyword: string, intent: KeywordIntent): KeywordResearchRow {
|
||||||
|
return {
|
||||||
|
keyword,
|
||||||
|
searchVolume: 100,
|
||||||
|
trend: [],
|
||||||
|
keywordDifficulty: 10,
|
||||||
|
cpc: 1,
|
||||||
|
competition: 0.5,
|
||||||
|
intent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filter(
|
||||||
|
rows: KeywordResearchRow[],
|
||||||
|
overrides: Partial<KeywordFilterValues>,
|
||||||
|
): KeywordResearchRow[] {
|
||||||
|
return applyKeywordFiltersAndSort({
|
||||||
|
rows,
|
||||||
|
filters: { ...EMPTY_FILTERS, ...overrides },
|
||||||
|
sortField: "keyword",
|
||||||
|
sortDir: "asc",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: KeywordResearchRow[] = [
|
||||||
|
makeRow("buy running shoes", "transactional"),
|
||||||
|
makeRow("best running shoes", "commercial"),
|
||||||
|
makeRow("how to run", "informational"),
|
||||||
|
makeRow("nike store", "navigational"),
|
||||||
|
makeRow("mystery term", "unknown"),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("parseIntentFilter", () => {
|
||||||
|
it("returns an empty list for an empty string", () => {
|
||||||
|
expect(parseIntentFilter("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a comma-separated string in canonical order", () => {
|
||||||
|
expect(parseIntentFilter("transactional,informational")).toEqual([
|
||||||
|
"informational",
|
||||||
|
"transactional",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops unknown tokens and de-duplicates", () => {
|
||||||
|
expect(parseIntentFilter("commercial,bogus,commercial")).toEqual([
|
||||||
|
"commercial",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleIntentFilter", () => {
|
||||||
|
it("adds an intent when absent and keeps canonical order", () => {
|
||||||
|
expect(toggleIntentFilter("transactional", "informational")).toBe(
|
||||||
|
"informational,transactional",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an intent when already present", () => {
|
||||||
|
expect(
|
||||||
|
toggleIntentFilter("informational,transactional", "informational"),
|
||||||
|
).toBe("transactional");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyKeywordFiltersAndSort — intent filtering", () => {
|
||||||
|
it("returns every row when no intent is selected", () => {
|
||||||
|
expect(filter(rows, { intents: "" })).toHaveLength(rows.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only rows matching a single selected intent", () => {
|
||||||
|
const result = filter(rows, { intents: "transactional" });
|
||||||
|
expect(result.map((r) => r.keyword)).toEqual(["buy running shoes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps rows matching any of multiple selected intents", () => {
|
||||||
|
const result = filter(rows, { intents: "transactional,commercial" });
|
||||||
|
expect(result.map((r) => r.keyword).toSorted()).toEqual([
|
||||||
|
"best running shoes",
|
||||||
|
"buy running shoes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines the intent filter with other filters (AND)", () => {
|
||||||
|
// "running" narrows to the two shoe rows; intent narrows to the commercial one.
|
||||||
|
const result = filter(rows, {
|
||||||
|
include: "running",
|
||||||
|
intents: "commercial",
|
||||||
|
});
|
||||||
|
expect(result.map((r) => r.keyword)).toEqual(["best running shoes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid intent tokens (treated as no intent match constraint)", () => {
|
||||||
|
const result = filter(rows, { intents: "bogus" });
|
||||||
|
expect(result).toHaveLength(rows.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,10 +2,13 @@ import { useMemo } from "react";
|
|||||||
import { sortBy } from "remeda";
|
import { sortBy } from "remeda";
|
||||||
import { parseTerms } from "@/client/features/keywords/utils";
|
import { parseTerms } from "@/client/features/keywords/utils";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { KeywordFilterValues } from "@/client/features/keywords/keywordResearchTypes";
|
import {
|
||||||
|
parseIntentFilter,
|
||||||
|
type KeywordFilterValues,
|
||||||
|
} from "@/client/features/keywords/keywordResearchTypes";
|
||||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||||
|
|
||||||
function applyKeywordFiltersAndSort(params: {
|
export function applyKeywordFiltersAndSort(params: {
|
||||||
rows: KeywordResearchRow[];
|
rows: KeywordResearchRow[];
|
||||||
filters: KeywordFilterValues;
|
filters: KeywordFilterValues;
|
||||||
sortField: SortField;
|
sortField: SortField;
|
||||||
@ -13,6 +16,7 @@ function applyKeywordFiltersAndSort(params: {
|
|||||||
}): KeywordResearchRow[] {
|
}): KeywordResearchRow[] {
|
||||||
const includeTerms = parseTerms(params.filters.include);
|
const includeTerms = parseTerms(params.filters.include);
|
||||||
const excludeTerms = parseTerms(params.filters.exclude);
|
const excludeTerms = parseTerms(params.filters.exclude);
|
||||||
|
const selectedIntents = parseIntentFilter(params.filters.intents);
|
||||||
|
|
||||||
const filtered = params.rows.filter((row) => {
|
const filtered = params.rows.filter((row) => {
|
||||||
const haystack = row.keyword.toLowerCase();
|
const haystack = row.keyword.toLowerCase();
|
||||||
@ -26,6 +30,10 @@ function applyKeywordFiltersAndSort(params: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedIntents.length > 0 && !selectedIntents.includes(row.intent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const vol = row.searchVolume ?? 0;
|
const vol = row.searchVolume ?? 0;
|
||||||
const cpc = row.cpc ?? 0;
|
const cpc = row.cpc ?? 0;
|
||||||
const kd = row.keywordDifficulty ?? 0;
|
const kd = row.keywordDifficulty ?? 0;
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { EMPTY_FILTERS } from "@/client/features/keywords/keywordResearchTypes";
|
||||||
|
import { filterValuesSchema } from "./useLocalKeywordFilters";
|
||||||
|
|
||||||
|
describe("filterValuesSchema — persistence migration", () => {
|
||||||
|
it("defaults intents to '' for blobs persisted before the intent filter existed", () => {
|
||||||
|
// A filter blob saved by an older build has no `intents` key.
|
||||||
|
const legacyBlob = {
|
||||||
|
include: "shoes",
|
||||||
|
exclude: "",
|
||||||
|
minVol: "100",
|
||||||
|
maxVol: "",
|
||||||
|
minCpc: "",
|
||||||
|
maxCpc: "",
|
||||||
|
minKd: "",
|
||||||
|
maxKd: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = filterValuesSchema.parse(legacyBlob);
|
||||||
|
|
||||||
|
expect(parsed.intents).toBe("");
|
||||||
|
// The rest of the user's saved filters survive the migration.
|
||||||
|
expect(parsed.include).toBe("shoes");
|
||||||
|
expect(parsed.minVol).toBe("100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a stored intents value", () => {
|
||||||
|
const parsed = filterValuesSchema.parse({
|
||||||
|
...EMPTY_FILTERS,
|
||||||
|
intents: "transactional,commercial",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.intents).toBe("transactional,commercial");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,7 +8,7 @@ import {
|
|||||||
|
|
||||||
const STORAGE_KEY = "keyword-default-filters";
|
const STORAGE_KEY = "keyword-default-filters";
|
||||||
|
|
||||||
const filterValuesSchema = z.object({
|
export const filterValuesSchema = z.object({
|
||||||
include: z.string(),
|
include: z.string(),
|
||||||
exclude: z.string(),
|
exclude: z.string(),
|
||||||
minVol: z.string(),
|
minVol: z.string(),
|
||||||
@ -17,6 +17,9 @@ const filterValuesSchema = z.object({
|
|||||||
maxCpc: z.string(),
|
maxCpc: z.string(),
|
||||||
minKd: z.string(),
|
minKd: z.string(),
|
||||||
maxKd: z.string(),
|
maxKd: z.string(),
|
||||||
|
// Defaulted so filter blobs persisted before the intent filter existed still
|
||||||
|
// parse (missing key -> "") instead of discarding the user's saved filters.
|
||||||
|
intents: z.string().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadFiltersFromStorage(): KeywordFilterValues {
|
function loadFiltersFromStorage(): KeywordFilterValues {
|
||||||
@ -63,6 +66,7 @@ export function useLocalKeywordFilters() {
|
|||||||
"maxCpc",
|
"maxCpc",
|
||||||
"minKd",
|
"minKd",
|
||||||
"maxKd",
|
"maxKd",
|
||||||
|
"intents",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { KeywordIntent } from "@/types/keywords";
|
||||||
|
|
||||||
export const MAX_KEYWORDS_PER_SUBMIT = 5;
|
export const MAX_KEYWORDS_PER_SUBMIT = 5;
|
||||||
|
|
||||||
export type ResultLimit = 150 | 300 | 500;
|
export type ResultLimit = 150 | 300 | 500;
|
||||||
@ -17,6 +19,14 @@ export type KeywordFilterValues = {
|
|||||||
maxCpc: string;
|
maxCpc: string;
|
||||||
minKd: string;
|
minKd: string;
|
||||||
maxKd: string;
|
maxKd: string;
|
||||||
|
/**
|
||||||
|
* Selected search intents, stored as a comma-separated string (e.g.
|
||||||
|
* "transactional,commercial") so it fits the all-strings filter shape used
|
||||||
|
* for form state, persistence, and active-filter counting. Empty = no intent
|
||||||
|
* filter. Use {@link parseIntentFilter} / {@link toggleIntentFilter} to read
|
||||||
|
* and edit it rather than parsing the string ad hoc.
|
||||||
|
*/
|
||||||
|
intents: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EMPTY_FILTERS: KeywordFilterValues = {
|
export const EMPTY_FILTERS: KeywordFilterValues = {
|
||||||
@ -28,4 +38,43 @@ export const EMPTY_FILTERS: KeywordFilterValues = {
|
|||||||
maxCpc: "",
|
maxCpc: "",
|
||||||
minKd: "",
|
minKd: "",
|
||||||
maxKd: "",
|
maxKd: "",
|
||||||
|
intents: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Canonical intent order for stable serialization and UI display. */
|
||||||
|
export const KEYWORD_INTENT_ORDER: KeywordIntent[] = [
|
||||||
|
"informational",
|
||||||
|
"commercial",
|
||||||
|
"transactional",
|
||||||
|
"navigational",
|
||||||
|
"unknown",
|
||||||
|
];
|
||||||
|
|
||||||
|
const KEYWORD_INTENT_SET = new Set<string>(KEYWORD_INTENT_ORDER);
|
||||||
|
|
||||||
|
/** Parses the stored intents string into a list of valid, de-duplicated intents. */
|
||||||
|
export function parseIntentFilter(value: string): KeywordIntent[] {
|
||||||
|
if (!value) return [];
|
||||||
|
const selected = new Set(
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part): part is KeywordIntent => KEYWORD_INTENT_SET.has(part)),
|
||||||
|
);
|
||||||
|
// Emit in canonical order so persistence and comparisons are deterministic.
|
||||||
|
return KEYWORD_INTENT_ORDER.filter((intent) => selected.has(intent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles one intent in the stored string, preserving canonical order. */
|
||||||
|
export function toggleIntentFilter(
|
||||||
|
value: string,
|
||||||
|
intent: KeywordIntent,
|
||||||
|
): string {
|
||||||
|
const selected = new Set(parseIntentFilter(value));
|
||||||
|
if (selected.has(intent)) {
|
||||||
|
selected.delete(intent);
|
||||||
|
} else {
|
||||||
|
selected.add(intent);
|
||||||
|
}
|
||||||
|
return KEYWORD_INTENT_ORDER.filter((item) => selected.has(item)).join(",");
|
||||||
|
}
|
||||||
|
|||||||
@ -23,9 +23,10 @@ import {
|
|||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
import {
|
import {
|
||||||
|
FilterIntentSelect,
|
||||||
FilterRangeInputs,
|
FilterRangeInputs,
|
||||||
FilterTextInput,
|
FilterTextInput,
|
||||||
} from "./keywordResearchDesktopFilters";
|
} from "./keywordResearchFilters";
|
||||||
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||||
import {
|
import {
|
||||||
KeywordResearchPagination,
|
KeywordResearchPagination,
|
||||||
@ -330,6 +331,8 @@ function DesktopFilters({ controller }: Props) {
|
|||||||
maxName="maxKd"
|
maxName="maxKd"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FilterIntentSelect form={filtersForm} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
|
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
|
||||||
import { formatNumber } from "@/client/features/keywords/utils";
|
import { formatNumber } from "@/client/features/keywords/utils";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
|
import { EmptyFilterResults } from "./keywordResearchFilters";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
activeFilterCount: number;
|
activeFilterCount: number;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { SerpAnalysisCard } from "@/client/features/keywords/components";
|
import { SerpAnalysisCard } from "@/client/features/keywords/components";
|
||||||
|
import { FilterIntentSelect } from "./keywordResearchFilters";
|
||||||
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||||
import {
|
import {
|
||||||
KeywordResearchPagination,
|
KeywordResearchPagination,
|
||||||
@ -322,6 +323,8 @@ function MobileFilters({ controller }: Props) {
|
|||||||
placeholder="Max difficulty"
|
placeholder="Max difficulty"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FilterIntentSelect form={filtersForm} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,63 @@
|
|||||||
|
import {
|
||||||
|
KEYWORD_INTENT_ORDER,
|
||||||
|
parseIntentFilter,
|
||||||
|
toggleIntentFilter,
|
||||||
|
} from "@/client/features/keywords/keywordResearchTypes";
|
||||||
|
import { INTENT_LABELS } from "@/client/features/keywords/components/IntentBadge";
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
|
|
||||||
|
export function FilterIntentSelect({
|
||||||
|
form,
|
||||||
|
}: {
|
||||||
|
form: KeywordResearchControllerState["filtersForm"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="keyword-intent-filter-label"
|
||||||
|
className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
id="keyword-intent-filter-label"
|
||||||
|
className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60"
|
||||||
|
>
|
||||||
|
Intent
|
||||||
|
</p>
|
||||||
|
<form.Field name="intents">
|
||||||
|
{(field) => {
|
||||||
|
const selected = parseIntentFilter(field.state.value);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{KEYWORD_INTENT_ORDER.map((intent) => {
|
||||||
|
const isActive = selected.includes(intent);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={intent}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
className={`btn btn-xs ${
|
||||||
|
isActive
|
||||||
|
? "btn-primary"
|
||||||
|
: "btn-ghost border border-base-300"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
field.handleChange(
|
||||||
|
toggleIntentFilter(field.state.value, intent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{INTENT_LABELS[intent]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FilterTextInput({
|
export function FilterTextInput({
|
||||||
form,
|
form,
|
||||||
name,
|
name,
|
||||||
Loading…
x
Reference in New Issue
Block a user