Add blank-value filtering to column filters

Each filter dropdown (text/category/date) gains a 'show blanks only'
checkbox; when on, the column filters to rows where the field is empty
or whitespace-only (0/false are not blank), and the value control is
disabled. Implemented via a BLANK_SENTINEL key handled by matchesFilter,
with isBlank + 4 new unit tests. Not deployed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-06-18 10:16:14 +08:00
parent 2f3cd9fa06
commit 933b779a10
3 changed files with 114 additions and 77 deletions

View File

@@ -4,7 +4,12 @@ import { Button, Input, Checkbox, DatePicker, Space } from "antd";
import dayjs from "dayjs";
import type { ColumnType } from "antd/es/table";
import type { FilterDropdownProps } from "antd/es/table/interface";
import { inferFilterType, distinctValues, matchesFilter } from "./table-filters";
import {
BLANK_SENTINEL,
inferFilterType,
distinctValues,
matchesFilter,
} from "./table-filters";
import type { DataRow, FilterType } from "./table-filters";
const DATE_FMT = "YYYY-MM-DD";
@@ -30,93 +35,84 @@ export function FilterDropdown({
type: FilterType;
distinct: string[];
}): React.ReactElement {
const blankOnly = selectedKeys.includes(BLANK_SENTINEL);
const footer = (
<Space style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
onClick={() => {
clearFilters?.();
confirm();
}}
>
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
</Button>
</Space>
);
let control: React.ReactNode;
if (type === "date") {
const [start, end] = decodeRange(selectedKeys);
return (
<div style={{ padding: 8 }}>
<DatePicker.RangePicker
value={[start, end]}
onChange={(dates) => {
if (!dates || dates[0] == null || dates[1] == null) {
setSelectedKeys([]);
} else {
const s = dates[0].format(DATE_FMT);
const e = dates[1].format(DATE_FMT);
setSelectedKeys([`${s}|${e}`]); // 单个编码键,供 onFilter 解码
}
}}
/>
<Space style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
onClick={() => {
clearFilters?.();
confirm();
}}
>
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
</Button>
</Space>
</div>
control = (
<DatePicker.RangePicker
disabled={blankOnly}
value={[start, end]}
onChange={(dates) => {
if (!dates || dates[0] == null || dates[1] == null) {
setSelectedKeys([]);
} else {
const s = dates[0].format(DATE_FMT);
const e = dates[1].format(DATE_FMT);
setSelectedKeys([`${s}|${e}`]); // 单个编码键,供 onFilter 解码
}
}}
style={{ width: "100%" }}
/>
);
}
if (type === "category") {
return (
<div style={{ padding: 8, maxWidth: 260 }}>
<Checkbox.Group
style={{ display: "flex", flexDirection: "column", maxHeight: 240, overflow: "auto" }}
value={(selectedKeys as string[]) ?? []}
onChange={(vals) => setSelectedKeys(vals as React.Key[])}
options={distinct}
/>
<Space style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
onClick={() => {
clearFilters?.();
confirm();
}}
>
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
</Button>
</Space>
</div>
} else if (type === "category") {
control = (
<Checkbox.Group
disabled={blankOnly}
style={{ display: "flex", flexDirection: "column", maxHeight: 240, overflow: "auto" }}
value={(selectedKeys as string[]) ?? []}
onChange={(vals) => setSelectedKeys(vals as React.Key[])}
options={distinct}
/>
);
}
// text
const textValue = selectedKeys[0] != null ? String(selectedKeys[0]) : "";
return (
<div style={{ padding: 8 }}>
} else {
// text
const textValue = blankOnly
? ""
: selectedKeys[0] != null
? String(selectedKeys[0])
: "";
control = (
<Input
disabled={blankOnly}
placeholder="输入关键字"
value={textValue}
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
onPressEnter={() => confirm()}
style={{ marginBottom: 8, display: "block" }}
allowClear
/>
<Space style={{ display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
onClick={() => {
clearFilters?.();
confirm();
}}
>
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
</Button>
</Space>
);
}
return (
<div style={{ padding: 8, maxWidth: type === "category" ? 260 : 320 }}>
<Checkbox
checked={blankOnly}
onChange={(e) => setSelectedKeys(e.target.checked ? [BLANK_SENTINEL] : [])}
style={{ marginBottom: 8 }}
>
</Checkbox>
{control}
{footer}
</div>
);
}

View File

@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import {
BLANK_SENTINEL,
CATEGORY_THRESHOLD,
distinctValues,
inferFilterType,
isBlank,
matchesFilter,
nextLockedRow,
} from "./table-filters";
@@ -79,3 +81,30 @@ describe("nextLockedRow", () => {
expect(nextLockedRow("5", "7")).toBe("7");
});
});
describe("isBlank", () => {
it("null/undefined/empty/whitespace are blank", () => {
expect(isBlank(null)).toBe(true);
expect(isBlank(undefined)).toBe(true);
expect(isBlank("")).toBe(true);
expect(isBlank(" ")).toBe(true);
});
it("non-empty values (including 0/false) are not blank", () => {
expect(isBlank("x")).toBe(false);
expect(isBlank(0)).toBe(false);
expect(isBlank(false)).toBe(false);
});
});
describe("matchesFilter blank sentinel", () => {
it("sentinel matches blank cells for any type", () => {
expect(matchesFilter("text", null, BLANK_SENTINEL)).toBe(true);
expect(matchesFilter("category", "", BLANK_SENTINEL)).toBe(true);
expect(matchesFilter("date", " ", BLANK_SENTINEL)).toBe(true);
});
it("sentinel does not match non-blank cells", () => {
expect(matchesFilter("text", "hello", BLANK_SENTINEL)).toBe(false);
expect(matchesFilter("category", "A", BLANK_SENTINEL)).toBe(false);
expect(matchesFilter("date", "2024-01-01", BLANK_SENTINEL)).toBe(false);
});
});

View File

@@ -35,8 +35,18 @@ export function inferFilterType(
return "text";
}
/** Sentinel filter key meaning "match blank/empty cells". */
export const BLANK_SENTINEL = "__blank__";
/** A cell is blank when it is null/undefined or trims to empty. 0/false are NOT blank. */
export function isBlank(value: unknown): boolean {
if (value === null || value === undefined) return true;
return String(value).trim() === "";
}
/**
* Does a cell match a single filter key?
* - BLANK_SENTINEL: cell is blank (any type)
* - text: case-insensitive substring
* - category: exact equality
* - date: range encoded as "start|end" (either side may be ""), ISO string compare
@@ -46,6 +56,8 @@ export function matchesFilter(
cellValue: unknown,
filterValue: string
): boolean {
if (filterValue === BLANK_SENTINEL) return isBlank(cellValue);
const cell =
cellValue === null || cellValue === undefined ? "" : String(cellValue);