Add Rank Tracking metric filters and fix null-last sort ordering (#75)
This commit is contained in:
parent
172126aec7
commit
65f7baf8a1
@ -1,6 +1,6 @@
|
||||
import { useMemo, type MutableRefObject } from "react";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
|
||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||
import { formatLocationLabel } from "@/shared/keyword-locations";
|
||||
@ -61,22 +61,13 @@ export function SortableHeader({
|
||||
);
|
||||
}
|
||||
|
||||
const nullsLastNumeric: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
|
||||
const a = rowA.getValue<number | null>(columnId);
|
||||
const b = rowB.getValue<number | null>(columnId);
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return 1;
|
||||
if (b == null) return -1;
|
||||
return a - b;
|
||||
};
|
||||
|
||||
// Local configs fetch volume scoped to the tracked city, so the header must
|
||||
// say which number the user is looking at — national volume can overstate
|
||||
// local demand by orders of magnitude.
|
||||
function makeVolumeColumn(locationLabel?: string): ColumnDef<RankTrackingRow> {
|
||||
return {
|
||||
id: "volume",
|
||||
accessorKey: "searchVolume",
|
||||
accessorFn: (row) => row.searchVolume ?? undefined,
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
column={column}
|
||||
@ -90,29 +81,35 @@ function makeVolumeColumn(locationLabel?: string): ColumnDef<RankTrackingRow> {
|
||||
/>
|
||||
),
|
||||
size: 90,
|
||||
cell: ({ getValue }) => <VolumeCell value={getValue<number | null>()} />,
|
||||
sortingFn: nullsLastNumeric,
|
||||
cell: ({ getValue }) => (
|
||||
<VolumeCell value={getValue<number | undefined>() ?? null} />
|
||||
),
|
||||
sortUndefined: "last",
|
||||
};
|
||||
}
|
||||
|
||||
const kdColumn: ColumnDef<RankTrackingRow> = {
|
||||
id: "kd",
|
||||
accessorKey: "keywordDifficulty",
|
||||
accessorFn: (row) => row.keywordDifficulty ?? undefined,
|
||||
header: ({ column }) => <SortableHeader column={column} label="KD" id="kd" />,
|
||||
size: 70,
|
||||
cell: ({ getValue }) => <DifficultyCell value={getValue<number | null>()} />,
|
||||
sortingFn: nullsLastNumeric,
|
||||
cell: ({ getValue }) => (
|
||||
<DifficultyCell value={getValue<number | undefined>() ?? null} />
|
||||
),
|
||||
sortUndefined: "last",
|
||||
};
|
||||
|
||||
const cpcColumn: ColumnDef<RankTrackingRow> = {
|
||||
id: "cpc",
|
||||
accessorKey: "cpc",
|
||||
accessorFn: (row) => row.cpc ?? undefined,
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="CPC" id="cpc" />
|
||||
),
|
||||
size: 80,
|
||||
cell: ({ getValue }) => <CpcCell value={getValue<number | null>()} />,
|
||||
sortingFn: nullsLastNumeric,
|
||||
cell: ({ getValue }) => (
|
||||
<CpcCell value={getValue<number | undefined>() ?? null} />
|
||||
),
|
||||
sortUndefined: "last",
|
||||
};
|
||||
|
||||
function makeKeywordColumn(
|
||||
@ -144,14 +141,14 @@ function makeDeviceColumn(
|
||||
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||
return {
|
||||
id,
|
||||
accessorFn: (row) => row[device].position,
|
||||
accessorFn: (row) => row[device].position ?? undefined,
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="Position" id={id} />
|
||||
),
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: ({ row }) => <DeviceRankCell result={row.original[device]} />,
|
||||
sortingFn: nullsLastNumeric,
|
||||
sortUndefined: "last",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
236
src/client/features/rank-tracking/RankTrackingFilters.logic.ts
Normal file
236
src/client/features/rank-tracking/RankTrackingFilters.logic.ts
Normal file
@ -0,0 +1,236 @@
|
||||
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;
|
||||
exclude: string;
|
||||
minDesktopPos: string;
|
||||
maxDesktopPos: string;
|
||||
minMobilePos: string;
|
||||
maxMobilePos: string;
|
||||
minVolume: string;
|
||||
maxVolume: string;
|
||||
minKd: string;
|
||||
maxKd: string;
|
||||
minCpc: string;
|
||||
maxCpc: 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: "",
|
||||
minDesktopPos: "",
|
||||
maxDesktopPos: "",
|
||||
minMobilePos: "",
|
||||
maxMobilePos: "",
|
||||
minVolume: "",
|
||||
maxVolume: "",
|
||||
minKd: "",
|
||||
maxKd: "",
|
||||
minCpc: "",
|
||||
maxCpc: "",
|
||||
};
|
||||
|
||||
export const EMPTY_DOMAIN_LIST_FILTERS: DomainListFilters = {
|
||||
query: "",
|
||||
device: "all",
|
||||
locationCode: "all",
|
||||
};
|
||||
|
||||
const DEVICE_FILTER_ORDER: RankTrackingConfig["devices"][] = [
|
||||
"both",
|
||||
"desktop",
|
||||
"mobile",
|
||||
];
|
||||
|
||||
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,
|
||||
): RankTrackingRow[] {
|
||||
const includeTerms = filters.include
|
||||
? filters.include
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const excludeTerms = filters.exclude
|
||||
? filters.exclude
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return rows.filter((row) => {
|
||||
const kw = row.keyword.toLowerCase();
|
||||
|
||||
if (includeTerms.length > 0 && !includeTerms.some((t) => kw.includes(t)))
|
||||
return false;
|
||||
|
||||
if (excludeTerms.some((t) => kw.includes(t))) return false;
|
||||
|
||||
if (
|
||||
!matchesPositionFilter(
|
||||
row.desktop.position,
|
||||
filters.minDesktopPos,
|
||||
filters.maxDesktopPos,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
if (
|
||||
!matchesPositionFilter(
|
||||
row.mobile.position,
|
||||
filters.minMobilePos,
|
||||
filters.maxMobilePos,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
if (
|
||||
!matchesMetricRangeFilter(
|
||||
row.searchVolume,
|
||||
filters.minVolume,
|
||||
filters.maxVolume,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
if (
|
||||
!matchesMetricRangeFilter(
|
||||
row.keywordDifficulty,
|
||||
filters.minKd,
|
||||
filters.maxKd,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
if (!matchesMetricRangeFilter(row.cpc, filters.minCpc, filters.maxCpc))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesPositionFilter(
|
||||
position: number | null,
|
||||
minValue: string,
|
||||
maxValue: string,
|
||||
): boolean {
|
||||
if (!minValue && !maxValue) return true;
|
||||
|
||||
const max = maxValue === "" ? Infinity : Number(maxValue);
|
||||
if (max === 0) return position === null;
|
||||
|
||||
if (position === null) return false;
|
||||
|
||||
const min = minValue === "" ? 0 : Number(minValue);
|
||||
return position >= min && position <= max;
|
||||
}
|
||||
|
||||
export function matchesMetricRangeFilter(
|
||||
value: number | null,
|
||||
minValue: string,
|
||||
maxValue: string,
|
||||
): boolean {
|
||||
if (!minValue && !maxValue) return true;
|
||||
if (value === null) return false;
|
||||
|
||||
const min = minValue === "" ? -Infinity : Number(minValue);
|
||||
const max = maxValue === "" ? Infinity : Number(maxValue);
|
||||
return value >= min && value <= max;
|
||||
}
|
||||
|
||||
export function countActiveFilters(filters: Filters): number {
|
||||
let count = 0;
|
||||
if (filters.include) count++;
|
||||
if (filters.exclude) count++;
|
||||
if (filters.minDesktopPos || filters.maxDesktopPos) count++;
|
||||
if (filters.minMobilePos || filters.maxMobilePos) count++;
|
||||
if (filters.minVolume || filters.maxVolume) count++;
|
||||
if (filters.minKd || filters.maxKd) count++;
|
||||
if (filters.minCpc || filters.maxCpc) 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;
|
||||
}
|
||||
@ -7,6 +7,7 @@ import {
|
||||
EMPTY_DOMAIN_LIST_FILTERS,
|
||||
EMPTY_FILTERS,
|
||||
getDomainListFilterOptions,
|
||||
matchesMetricRangeFilter,
|
||||
matchesPositionFilter,
|
||||
type DomainListFilters,
|
||||
type Filters,
|
||||
@ -23,13 +24,18 @@ function makeRow(
|
||||
keyword: string,
|
||||
desktopPosition: number | null,
|
||||
mobilePosition: number | null,
|
||||
metrics: {
|
||||
volume?: number | null;
|
||||
kd?: number | null;
|
||||
cpc?: number | null;
|
||||
} = {},
|
||||
): RankTrackingRow {
|
||||
return {
|
||||
trackingKeywordId: keyword,
|
||||
keyword,
|
||||
searchVolume: null,
|
||||
keywordDifficulty: null,
|
||||
cpc: null,
|
||||
searchVolume: metrics.volume ?? null,
|
||||
keywordDifficulty: metrics.kd ?? null,
|
||||
cpc: metrics.cpc ?? null,
|
||||
desktop: {
|
||||
position: desktopPosition,
|
||||
previousPosition: null,
|
||||
@ -210,3 +216,67 @@ describe("countActiveDomainListFilters", () => {
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesMetricRangeFilter", () => {
|
||||
it("matches everything when no bounds are set", () => {
|
||||
expect(matchesMetricRangeFilter(null, "", "")).toBe(true);
|
||||
expect(matchesMetricRangeFilter(500, "", "")).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes null values once a bound is set", () => {
|
||||
expect(matchesMetricRangeFilter(null, "10", "")).toBe(false);
|
||||
expect(matchesMetricRangeFilter(null, "", "100")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats zero as a real value, not 'no data'", () => {
|
||||
expect(matchesMetricRangeFilter(0, "0", "10")).toBe(true);
|
||||
expect(matchesMetricRangeFilter(0, "1", "10")).toBe(false);
|
||||
});
|
||||
|
||||
it("respects min-only and max-only bounds", () => {
|
||||
expect(matchesMetricRangeFilter(50, "20", "")).toBe(true);
|
||||
expect(matchesMetricRangeFilter(10, "20", "")).toBe(false);
|
||||
expect(matchesMetricRangeFilter(50, "", "40")).toBe(false);
|
||||
expect(matchesMetricRangeFilter(50, "", "60")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFilters metric ranges", () => {
|
||||
const rows = [
|
||||
makeRow("high volume", 3, 6, { volume: 5000, kd: 40, cpc: 2.5 }),
|
||||
makeRow("low volume", 3, 6, { volume: 10, kd: 80, cpc: 0.1 }),
|
||||
makeRow("no data", 3, 6, {}),
|
||||
];
|
||||
|
||||
it("filters by volume range", () => {
|
||||
expect(
|
||||
applyFilters(rows, withFilters({ minVolume: "100" })).map(
|
||||
(row) => row.keyword,
|
||||
),
|
||||
).toEqual(["high volume"]);
|
||||
});
|
||||
|
||||
it("filters by KD range", () => {
|
||||
expect(
|
||||
applyFilters(rows, withFilters({ maxKd: "50" })).map(
|
||||
(row) => row.keyword,
|
||||
),
|
||||
).toEqual(["high volume"]);
|
||||
});
|
||||
|
||||
it("filters by CPC range", () => {
|
||||
expect(
|
||||
applyFilters(rows, withFilters({ minCpc: "1" })).map(
|
||||
(row) => row.keyword,
|
||||
),
|
||||
).toEqual(["high volume"]);
|
||||
});
|
||||
|
||||
it("excludes rows without metric data once a metric filter is active", () => {
|
||||
expect(
|
||||
applyFilters(rows, withFilters({ minVolume: "0" })).map(
|
||||
(row) => row.keyword,
|
||||
),
|
||||
).toEqual(["high volume", "low volume"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,57 +1,13 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
||||
import { devicesLabel } from "@/shared/rank-tracking";
|
||||
import type {
|
||||
RankTrackingConfig,
|
||||
RankTrackingRow,
|
||||
} from "@/types/schemas/rank-tracking";
|
||||
import type { DomainListFilters, Filters } from "./RankTrackingFilters.logic";
|
||||
|
||||
export type Filters = {
|
||||
include: string;
|
||||
exclude: string;
|
||||
minDesktopPos: string;
|
||||
maxDesktopPos: string;
|
||||
minMobilePos: string;
|
||||
maxMobilePos: string;
|
||||
};
|
||||
|
||||
type DomainFilterableConfig = Pick<
|
||||
RankTrackingConfig,
|
||||
"domain" | "devices" | "locationCode"
|
||||
>;
|
||||
|
||||
export type DomainListFilters = {
|
||||
query: string;
|
||||
device: "all" | RankTrackingConfig["devices"];
|
||||
locationCode: string;
|
||||
};
|
||||
export * from "./RankTrackingFilters.logic";
|
||||
|
||||
type DomainListFilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const EMPTY_FILTERS: Filters = {
|
||||
include: "",
|
||||
exclude: "",
|
||||
minDesktopPos: "",
|
||||
maxDesktopPos: "",
|
||||
minMobilePos: "",
|
||||
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,
|
||||
@ -126,6 +82,29 @@ export function FilterPanel({
|
||||
onMaxChange={(v) => update("maxMobilePos", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<RangeFilter
|
||||
title="Volume"
|
||||
minValue={filters.minVolume}
|
||||
maxValue={filters.maxVolume}
|
||||
onMinChange={(v) => update("minVolume", v)}
|
||||
onMaxChange={(v) => update("maxVolume", v)}
|
||||
/>
|
||||
<RangeFilter
|
||||
title="Keyword difficulty"
|
||||
minValue={filters.minKd}
|
||||
maxValue={filters.maxKd}
|
||||
onMinChange={(v) => update("minKd", v)}
|
||||
onMaxChange={(v) => update("maxKd", v)}
|
||||
/>
|
||||
<RangeFilter
|
||||
title="CPC"
|
||||
minValue={filters.minCpc}
|
||||
maxValue={filters.maxCpc}
|
||||
onMinChange={(v) => update("minCpc", v)}
|
||||
onMaxChange={(v) => update("maxCpc", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -262,138 +241,3 @@ function RangeFilter({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
): RankTrackingRow[] {
|
||||
const includeTerms = filters.include
|
||||
? filters.include
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const excludeTerms = filters.exclude
|
||||
? filters.exclude
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return rows.filter((row) => {
|
||||
const kw = row.keyword.toLowerCase();
|
||||
|
||||
if (includeTerms.length > 0 && !includeTerms.some((t) => kw.includes(t)))
|
||||
return false;
|
||||
|
||||
if (excludeTerms.some((t) => kw.includes(t))) return false;
|
||||
|
||||
if (
|
||||
!matchesPositionFilter(
|
||||
row.desktop.position,
|
||||
filters.minDesktopPos,
|
||||
filters.maxDesktopPos,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
if (
|
||||
!matchesPositionFilter(
|
||||
row.mobile.position,
|
||||
filters.minMobilePos,
|
||||
filters.maxMobilePos,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesPositionFilter(
|
||||
position: number | null,
|
||||
minValue: string,
|
||||
maxValue: string,
|
||||
): boolean {
|
||||
if (!minValue && !maxValue) return true;
|
||||
|
||||
const max = maxValue === "" ? Infinity : Number(maxValue);
|
||||
if (max === 0) return position === null;
|
||||
|
||||
if (position === null) return false;
|
||||
|
||||
const min = minValue === "" ? 0 : Number(minValue);
|
||||
return position >= min && position <= max;
|
||||
}
|
||||
|
||||
export function countActiveFilters(filters: Filters): number {
|
||||
let count = 0;
|
||||
if (filters.include) count++;
|
||||
if (filters.exclude) count++;
|
||||
if (filters.minDesktopPos || filters.maxDesktopPos) count++;
|
||||
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