diff --git a/src/app/table-filter-ui.tsx b/src/app/table-filter-ui.tsx index 6a190f3..7b88010 100644 --- a/src/app/table-filter-ui.tsx +++ b/src/app/table-filter-ui.tsx @@ -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 = ( + + + + + ); + + let control: React.ReactNode; if (type === "date") { const [start, end] = decodeRange(selectedKeys); - return ( -
- { - 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 解码 - } - }} - /> - - - - -
+ control = ( + { + 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 ( -
- setSelectedKeys(vals as React.Key[])} - options={distinct} - /> - - - - -
+ } else if (type === "category") { + control = ( + setSelectedKeys(vals as React.Key[])} + options={distinct} + /> ); - } - - // text - const textValue = selectedKeys[0] != null ? String(selectedKeys[0]) : ""; - return ( -
+ } else { + // text + const textValue = blankOnly + ? "" + : selectedKeys[0] != null + ? String(selectedKeys[0]) + : ""; + control = ( setSelectedKeys(e.target.value ? [e.target.value] : [])} onPressEnter={() => confirm()} - style={{ marginBottom: 8, display: "block" }} allowClear /> - - - - + ); + } + + return ( +
+ setSelectedKeys(e.target.checked ? [BLANK_SENTINEL] : [])} + style={{ marginBottom: 8 }} + > + 仅显示空白 + + {control} + {footer}
); } diff --git a/src/app/table-filters.test.ts b/src/app/table-filters.test.ts index 2123d69..c6e726c 100644 --- a/src/app/table-filters.test.ts +++ b/src/app/table-filters.test.ts @@ -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); + }); +}); diff --git a/src/app/table-filters.ts b/src/app/table-filters.ts index f3cebcd..5d972da 100644 --- a/src/app/table-filters.ts +++ b/src/app/table-filters.ts @@ -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);