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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user