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:
parent
a19baed678
commit
200a6e8b61
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
@ -9,6 +9,7 @@ import {
|
||||
Globe,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getRankTrackingConfigSummaries,
|
||||
@ -16,11 +17,24 @@ import {
|
||||
} from "@/serverFunctions/rank-tracking";
|
||||
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
import {
|
||||
applyDomainListFilters,
|
||||
countActiveDomainListFilters,
|
||||
DomainListFilterBar,
|
||||
EMPTY_DOMAIN_LIST_FILTERS,
|
||||
getDomainListFilterOptions,
|
||||
type DomainListFilters,
|
||||
} from "./RankTrackingFilters";
|
||||
|
||||
type ConfigSummary = Awaited<
|
||||
ReturnType<typeof getRankTrackingConfigSummaries>
|
||||
>[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({
|
||||
projectId,
|
||||
onAddDomain,
|
||||
@ -32,10 +46,23 @@ export function RankTrackingDomainList({
|
||||
const [archiveTarget, setArchiveTarget] = useState<ConfigSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [filters, setFilters] = useState<DomainListFilters>(
|
||||
EMPTY_DOMAIN_LIST_FILTERS,
|
||||
);
|
||||
const { data: summaries } = useQuery({
|
||||
queryKey: ["rankTrackingConfigSummaries", 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({
|
||||
mutationFn: (configId: string) =>
|
||||
@ -67,8 +94,18 @@ export function RankTrackingDomainList({
|
||||
Add Domain
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-base-300">
|
||||
{(summaries ?? []).length === 0 ? (
|
||||
{(allSummaries.length >= FILTER_BAR_MIN_DOMAINS ||
|
||||
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="mx-auto flex size-10 items-center justify-center rounded-xl bg-base-200">
|
||||
<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.
|
||||
</p>
|
||||
</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
|
||||
key={summary.id}
|
||||
projectId={projectId}
|
||||
|
||||
@ -1,12 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||
import {
|
||||
applyDomainListFilters,
|
||||
applyFilters,
|
||||
countActiveDomainListFilters,
|
||||
EMPTY_DOMAIN_LIST_FILTERS,
|
||||
EMPTY_FILTERS,
|
||||
getDomainListFilterOptions,
|
||||
matchesPositionFilter,
|
||||
type DomainListFilters,
|
||||
type Filters,
|
||||
} from "./RankTrackingFilters";
|
||||
|
||||
type DomainSummary = {
|
||||
id: string;
|
||||
domain: string;
|
||||
devices: "both" | "desktop" | "mobile";
|
||||
locationCode: number;
|
||||
};
|
||||
|
||||
function makeRow(
|
||||
keyword: string,
|
||||
desktopPosition: number | null,
|
||||
@ -37,6 +49,21 @@ function withFilters(overrides: Partial<Filters>): Filters {
|
||||
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", () => {
|
||||
it("matches only unranked positions when max is zero", () => {
|
||||
expect(matchesPositionFilter(null, "", "0")).toBe(true);
|
||||
@ -84,3 +111,102 @@ describe("applyFilters", () => {
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
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 = {
|
||||
include: string;
|
||||
@ -10,6 +15,22 @@ export type Filters = {
|
||||
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 = {
|
||||
include: "",
|
||||
exclude: "",
|
||||
@ -19,6 +40,18 @@ export const EMPTY_FILTERS: Filters = {
|
||||
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({
|
||||
filters,
|
||||
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({
|
||||
title,
|
||||
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(
|
||||
rows: RankTrackingRow[],
|
||||
filters: Filters,
|
||||
@ -208,3 +387,13 @@ export function countActiveFilters(filters: Filters): number {
|
||||
if (filters.minMobilePos || filters.maxMobilePos) 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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user