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

View File

@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
BLANK_SENTINEL,
CATEGORY_THRESHOLD, CATEGORY_THRESHOLD,
distinctValues, distinctValues,
inferFilterType, inferFilterType,
isBlank,
matchesFilter, matchesFilter,
nextLockedRow, nextLockedRow,
} from "./table-filters"; } from "./table-filters";
@@ -79,3 +81,30 @@ describe("nextLockedRow", () => {
expect(nextLockedRow("5", "7")).toBe("7"); 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"; 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? * Does a cell match a single filter key?
* - BLANK_SENTINEL: cell is blank (any type)
* - text: case-insensitive substring * - text: case-insensitive substring
* - category: exact equality * - category: exact equality
* - date: range encoded as "start|end" (either side may be ""), ISO string compare * - date: range encoded as "start|end" (either side may be ""), ISO string compare
@@ -46,6 +56,8 @@ export function matchesFilter(
cellValue: unknown, cellValue: unknown,
filterValue: string filterValue: string
): boolean { ): boolean {
if (filterValue === BLANK_SENTINEL) return isBlank(cellValue);
const cell = const cell =
cellValue === null || cellValue === undefined ? "" : String(cellValue); cellValue === null || cellValue === undefined ? "" : String(cellValue);