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