feat: add search and filters to the Tracked Domains list (#39)

* feat: add search and filters to the Tracked Domains list

Client-side search + device/country filtering over the tracked-domains set,
handling multi-config domains. Closes #33.

* design: gate tracked-domains filter bar by volume and flatten its chrome

Hide the domain filter bar until there are enough rows to warrant it
(>= 6, or whenever a filter is active so it can't be orphaned), drop the
redundant 'Refine results' sub-header in favor of an inline clear control,
and remove the gradient band so the filter row shares the card surface
with a single divider under the header.

* review: fix failing test, drop dead exports, clear oxlint/knip errors

- Align getDomainListFilterOptions test fixtures with the real LOCATIONS
  map (short labels FR/UK), which the suite asserted as full names — the
  list rows already render short labels, so the dropdown matches them.
- Inline the single-use FilterPanelHeader back into FilterPanel (it was
  extracted to share with the domain filter bar, which no longer uses it).
- Validate the device <select> value instead of an unsafe type assertion,
  matching the config/header select idioms.
- Use toSorted() for location options; memoize allSummaries so dependent
  useMemos have a stable dependency; un-export the internal option type.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Ben Senescu <bensenescu@gmail.com>
This commit is contained in:
Matt Van Horn 2026-06-28 15:45:57 -07:00 committed by GitHub
parent a19baed678
commit 200a6e8b61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 378 additions and 5 deletions

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { toast } from "sonner"; import { toast } from "sonner";
@ -9,6 +9,7 @@ import {
Globe, Globe,
Plus, Plus,
ChevronRight, ChevronRight,
Search,
} from "lucide-react"; } from "lucide-react";
import { import {
getRankTrackingConfigSummaries, getRankTrackingConfigSummaries,
@ -16,11 +17,24 @@ import {
} from "@/serverFunctions/rank-tracking"; } from "@/serverFunctions/rank-tracking";
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking"; import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
import { Modal } from "@/client/components/Modal"; import { Modal } from "@/client/components/Modal";
import {
applyDomainListFilters,
countActiveDomainListFilters,
DomainListFilterBar,
EMPTY_DOMAIN_LIST_FILTERS,
getDomainListFilterOptions,
type DomainListFilters,
} from "./RankTrackingFilters";
type ConfigSummary = Awaited< type ConfigSummary = Awaited<
ReturnType<typeof getRankTrackingConfigSummaries> ReturnType<typeof getRankTrackingConfigSummaries>
>[number]; >[number];
// Below this many domains the list is short enough to scan by eye, so the
// filter controls are more chrome than help. Still shown if filters are active
// (e.g. archiving dropped the count) so they never get orphaned.
const FILTER_BAR_MIN_DOMAINS = 6;
export function RankTrackingDomainList({ export function RankTrackingDomainList({
projectId, projectId,
onAddDomain, onAddDomain,
@ -32,10 +46,23 @@ export function RankTrackingDomainList({
const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>( const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>(
null, null,
); );
const [filters, setFilters] = useState<DomainListFilters>(
EMPTY_DOMAIN_LIST_FILTERS,
);
const { data: summaries } = useQuery({ const { data: summaries } = useQuery({
queryKey: ["rankTrackingConfigSummaries", projectId], queryKey: ["rankTrackingConfigSummaries", projectId],
queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }), queryFn: () => getRankTrackingConfigSummaries({ data: { projectId } }),
}); });
const allSummaries = useMemo(() => summaries ?? [], [summaries]);
const filteredSummaries = useMemo(
() => applyDomainListFilters(allSummaries, filters),
[allSummaries, filters],
);
const filterOptions = useMemo(
() => getDomainListFilterOptions(allSummaries),
[allSummaries],
);
const activeFilterCount = countActiveDomainListFilters(filters);
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: (configId: string) => mutationFn: (configId: string) =>
@ -67,8 +94,18 @@ export function RankTrackingDomainList({
Add Domain Add Domain
</button> </button>
</div> </div>
<div className="divide-y divide-base-300"> {(allSummaries.length >= FILTER_BAR_MIN_DOMAINS ||
{(summaries ?? []).length === 0 ? ( activeFilterCount > 0) && (
<DomainListFilterBar
filters={filters}
options={filterOptions}
activeFilterCount={activeFilterCount}
onChange={setFilters}
onReset={() => setFilters(EMPTY_DOMAIN_LIST_FILTERS)}
/>
)}
<div className="divide-y divide-base-300 border-t border-base-300">
{allSummaries.length === 0 ? (
<div className="px-5 py-10 text-center space-y-2"> <div className="px-5 py-10 text-center space-y-2">
<div className="mx-auto flex size-10 items-center justify-center rounded-xl bg-base-200"> <div className="mx-auto flex size-10 items-center justify-center rounded-xl bg-base-200">
<Globe className="size-5 text-base-content/40" /> <Globe className="size-5 text-base-content/40" />
@ -80,8 +117,29 @@ export function RankTrackingDomainList({
Add a domain to start monitoring keyword rankings over time. Add a domain to start monitoring keyword rankings over time.
</p> </p>
</div> </div>
) : filteredSummaries.length === 0 ? (
<div className="px-5 py-10 text-center space-y-3">
<div className="mx-auto flex size-10 items-center justify-center rounded-xl bg-base-200">
<Search className="size-5 text-base-content/40" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-base-content/70">
No matching tracked domains
</p>
<p className="text-xs text-base-content/40">
Try clearing search or adjusting filters.
</p>
</div>
<button
className="btn btn-ghost btn-xs"
onClick={() => setFilters(EMPTY_DOMAIN_LIST_FILTERS)}
disabled={activeFilterCount === 0}
>
Clear filters
</button>
</div>
) : ( ) : (
(summaries ?? []).map((summary) => ( filteredSummaries.map((summary) => (
<DomainRow <DomainRow
key={summary.id} key={summary.id}
projectId={projectId} projectId={projectId}

View File

@ -1,12 +1,24 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { import {
applyDomainListFilters,
applyFilters, applyFilters,
countActiveDomainListFilters,
EMPTY_DOMAIN_LIST_FILTERS,
EMPTY_FILTERS, EMPTY_FILTERS,
getDomainListFilterOptions,
matchesPositionFilter, matchesPositionFilter,
type DomainListFilters,
type Filters, type Filters,
} from "./RankTrackingFilters"; } from "./RankTrackingFilters";
type DomainSummary = {
id: string;
domain: string;
devices: "both" | "desktop" | "mobile";
locationCode: number;
};
function makeRow( function makeRow(
keyword: string, keyword: string,
desktopPosition: number | null, desktopPosition: number | null,
@ -37,6 +49,21 @@ function withFilters(overrides: Partial<Filters>): Filters {
return { ...EMPTY_FILTERS, ...overrides }; return { ...EMPTY_FILTERS, ...overrides };
} }
function makeSummary(
id: string,
domain: string,
devices: DomainSummary["devices"],
locationCode: number,
): DomainSummary {
return { id, domain, devices, locationCode };
}
function withDomainFilters(
overrides: Partial<DomainListFilters>,
): DomainListFilters {
return { ...EMPTY_DOMAIN_LIST_FILTERS, ...overrides };
}
describe("matchesPositionFilter", () => { describe("matchesPositionFilter", () => {
it("matches only unranked positions when max is zero", () => { it("matches only unranked positions when max is zero", () => {
expect(matchesPositionFilter(null, "", "0")).toBe(true); expect(matchesPositionFilter(null, "", "0")).toBe(true);
@ -84,3 +111,102 @@ describe("applyFilters", () => {
).toEqual(["unranked both"]); ).toEqual(["unranked both"]);
}); });
}); });
describe("applyDomainListFilters", () => {
const summaries = [
makeSummary("alpha-us-mobile", "alpha.example.com", "mobile", 2840),
makeSummary("alpha-fr-desktop", "alpha.example.com", "desktop", 2250),
makeSummary("alpha-fr-mobile", "alpha.example.com", "mobile", 2250),
makeSummary("bravo-fr-both", "bravo.example.com", "both", 2250),
makeSummary("charlie-uk-desktop", "charlie.example.com", "desktop", 2826),
];
it("narrows by text query and restores all when cleared", () => {
expect(
applyDomainListFilters(
summaries,
withDomainFilters({ query: "ALPHA" }),
).map((summary) => summary.id),
).toEqual(["alpha-us-mobile", "alpha-fr-desktop", "alpha-fr-mobile"]);
expect(
applyDomainListFilters(summaries, EMPTY_DOMAIN_LIST_FILTERS).map(
(summary) => summary.id,
),
).toEqual(summaries.map((summary) => summary.id));
});
it("filters by device", () => {
expect(
applyDomainListFilters(
summaries,
withDomainFilters({ device: "mobile" }),
).map((summary) => summary.id),
).toEqual(["alpha-us-mobile", "alpha-fr-mobile"]);
});
it("filters by country", () => {
expect(
applyDomainListFilters(
summaries,
withDomainFilters({ locationCode: "2250" }),
).map((summary) => summary.id),
).toEqual(["alpha-fr-desktop", "alpha-fr-mobile", "bravo-fr-both"]);
});
it("combines domain, device, and country filters with AND semantics", () => {
expect(
applyDomainListFilters(
summaries,
withDomainFilters({
query: "alpha",
device: "mobile",
locationCode: "2250",
}),
).map((summary) => summary.id),
).toEqual(["alpha-fr-mobile"]);
});
it("returns an empty list when filters match nothing", () => {
expect(
applyDomainListFilters(
summaries,
withDomainFilters({ query: "missing", device: "mobile" }),
),
).toEqual([]);
});
});
describe("getDomainListFilterOptions", () => {
it("derives distinct device and country options from available summaries", () => {
const options = getDomainListFilterOptions([
makeSummary("a", "a.com", "mobile", 2250),
makeSummary("b", "b.com", "desktop", 2250),
makeSummary("c", "c.com", "mobile", 2826),
]);
expect(options.devices).toEqual([
{ value: "desktop", label: "Desktop" },
{ value: "mobile", label: "Mobile" },
]);
expect(options.locations).toEqual([
{ value: "2250", label: "FR" },
{ value: "2826", label: "UK" },
]);
});
});
describe("countActiveDomainListFilters", () => {
it("counts non-empty domain list filters", () => {
expect(countActiveDomainListFilters(EMPTY_DOMAIN_LIST_FILTERS)).toBe(0);
expect(
countActiveDomainListFilters(
withDomainFilters({
query: "alpha",
device: "desktop",
locationCode: "2840",
}),
),
).toBe(3);
});
});

View File

@ -1,5 +1,10 @@
import { RotateCcw } from "lucide-react"; import { RotateCcw } from "lucide-react";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import { LOCATIONS } from "@/client/features/keywords/locations";
import { devicesLabel } from "@/shared/rank-tracking";
import type {
RankTrackingConfig,
RankTrackingRow,
} from "@/types/schemas/rank-tracking";
export type Filters = { export type Filters = {
include: string; include: string;
@ -10,6 +15,22 @@ export type Filters = {
maxMobilePos: string; maxMobilePos: string;
}; };
type DomainFilterableConfig = Pick<
RankTrackingConfig,
"domain" | "devices" | "locationCode"
>;
export type DomainListFilters = {
query: string;
device: "all" | RankTrackingConfig["devices"];
locationCode: string;
};
type DomainListFilterOption = {
value: string;
label: string;
};
export const EMPTY_FILTERS: Filters = { export const EMPTY_FILTERS: Filters = {
include: "", include: "",
exclude: "", exclude: "",
@ -19,6 +40,18 @@ export const EMPTY_FILTERS: Filters = {
maxMobilePos: "", maxMobilePos: "",
}; };
export const EMPTY_DOMAIN_LIST_FILTERS: DomainListFilters = {
query: "",
device: "all",
locationCode: "all",
};
const DEVICE_FILTER_ORDER: RankTrackingConfig["devices"][] = [
"both",
"desktop",
"mobile",
];
export function FilterPanel({ export function FilterPanel({
filters, filters,
setFilters, setFilters,
@ -97,6 +130,101 @@ export function FilterPanel({
); );
} }
export function DomainListFilterBar({
filters,
options,
activeFilterCount,
onChange,
onReset,
}: {
filters: DomainListFilters;
options: {
devices: DomainListFilterOption[];
locations: DomainListFilterOption[];
};
activeFilterCount: number;
onChange: (filters: DomainListFilters) => void;
onReset: () => void;
}) {
return (
<div className="border-t border-base-300 px-5 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<label className="form-control flex-1 gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Search
</span>
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder="Domain or website"
value={filters.query}
onChange={(event) =>
onChange({ ...filters, query: event.target.value })
}
/>
</label>
<label className="form-control gap-1.5 lg:w-44">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Device
</span>
<select
className="select select-bordered select-sm w-full bg-base-100"
value={filters.device}
onChange={(event) => {
const value = event.target.value;
if (
value === "all" ||
value === "both" ||
value === "desktop" ||
value === "mobile"
) {
onChange({ ...filters, device: value });
}
}}
>
<option value="all">All devices</option>
{options.devices.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="form-control gap-1.5 lg:w-52">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Country
</span>
<select
className="select select-bordered select-sm w-full bg-base-100"
value={filters.locationCode}
onChange={(event) =>
onChange({ ...filters, locationCode: event.target.value })
}
>
<option value="all">All countries</option>
{options.locations.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{activeFilterCount > 0 && (
<button
className="btn btn-ghost btn-sm gap-1.5 self-start lg:self-auto"
onClick={onReset}
>
<RotateCcw className="size-3" />
Clear
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</span>
</button>
)}
</div>
</div>
);
}
function RangeFilter({ function RangeFilter({
title, title,
minValue, minValue,
@ -135,6 +263,57 @@ function RangeFilter({
); );
} }
export function applyDomainListFilters<T extends DomainFilterableConfig>(
configs: T[],
filters: DomainListFilters,
): T[] {
const query = filters.query.trim().toLowerCase();
const locationCode =
filters.locationCode === "all" ? null : Number(filters.locationCode);
return configs.filter((config) => {
if (query && !config.domain.toLowerCase().includes(query)) return false;
if (filters.device !== "all" && config.devices !== filters.device) {
return false;
}
if (locationCode !== null && config.locationCode !== locationCode) {
return false;
}
return true;
});
}
export function getDomainListFilterOptions(configs: DomainFilterableConfig[]): {
devices: DomainListFilterOption[];
locations: DomainListFilterOption[];
} {
const deviceValues = new Set(configs.map((config) => config.devices));
const devices = DEVICE_FILTER_ORDER.filter((device) =>
deviceValues.has(device),
).map((device) => ({
value: device,
label: devicesLabel(device),
}));
const locationMap = new Map<number, string>();
for (const config of configs) {
locationMap.set(
config.locationCode,
LOCATIONS[config.locationCode] ?? String(config.locationCode),
);
}
const locations = Array.from(locationMap, ([code, label]) => ({
value: String(code),
label,
})).toSorted((a, b) => a.label.localeCompare(b.label));
return { devices, locations };
}
export function applyFilters( export function applyFilters(
rows: RankTrackingRow[], rows: RankTrackingRow[],
filters: Filters, filters: Filters,
@ -208,3 +387,13 @@ export function countActiveFilters(filters: Filters): number {
if (filters.minMobilePos || filters.maxMobilePos) count++; if (filters.minMobilePos || filters.maxMobilePos) count++;
return count; return count;
} }
export function countActiveDomainListFilters(
filters: DomainListFilters,
): number {
let count = 0;
if (filters.query.trim()) count++;
if (filters.device !== "all") count++;
if (filters.locationCode !== "all") count++;
return count;
}