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 { 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}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user