feat(web): DataTable groupBy with collapsible counted group headers
Groups keep first-seen order (or a custom order), headers show counts and toggle with aria-expanded; collapsed groups persist per storageKey in localStorage (read after mount, failures ignored). The trash list groups by registered directory when items span more than one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
abc69886ea
commit
312e70d0bc
4 files changed
+276
-52
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DataTable } from "./data-table.tsx";
|
||||
import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts";
|
||||
|
||||
type Row = { id: string; status: string; name: string };
|
||||
const rows: Row[] = [
|
||||
{ id: "1", status: "待识别", name: "a.mkv" },
|
||||
{ id: "2", status: "已登记", name: "b.mkv" },
|
||||
{ id: "3", status: "待识别", name: "c.mkv" },
|
||||
];
|
||||
|
||||
describe("DataTable groupBy:分组逻辑", () => {
|
||||
it("按首次出现顺序分组,组内保持原顺序", () => {
|
||||
expect(groupRows(rows, (r) => r.status).map((g) => [g.key, g.rows.map((r) => r.id)])).toEqual([
|
||||
["待识别", ["1", "3"]],
|
||||
["已登记", ["2"]],
|
||||
]);
|
||||
});
|
||||
it("给 order 时按 order 排组", () => {
|
||||
const g = groupRows(
|
||||
rows,
|
||||
(r) => r.status,
|
||||
(a, b) => a.localeCompare(b, "zh-CN"),
|
||||
);
|
||||
expect(g.map((x) => x.key)).toEqual(["待识别", "已登记"].sort((a, b) => a.localeCompare(b, "zh-CN")));
|
||||
});
|
||||
it("折叠切换返回新集合", () => {
|
||||
const a = new Set<string>();
|
||||
const b = toggleCollapsed(a, "x");
|
||||
expect([...b]).toEqual(["x"]);
|
||||
expect(a.size).toBe(0);
|
||||
expect([...toggleCollapsed(b, "x")]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable groupBy:记住折叠态", () => {
|
||||
it("写入后能读回;没有 storageKey、内容损坏或存储不可用时为 null", () => {
|
||||
const map = new Map<string, string>();
|
||||
const store = () => ({
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void map.set(k, v),
|
||||
});
|
||||
writeCollapsed("t", new Set(["已登记"]), store);
|
||||
expect([...(readCollapsed("t", store) ?? [])]).toEqual(["已登记"]);
|
||||
expect(readCollapsed(undefined, store)).toBeNull();
|
||||
map.set("umt.table-groups:bad", "{oops");
|
||||
expect(readCollapsed("bad", store)).toBeNull();
|
||||
const blocked = () => ({
|
||||
getItem: (): string | null => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
});
|
||||
expect(readCollapsed("t", blocked)).toBeNull();
|
||||
expect(() => writeCollapsed("t", new Set(), blocked)).not.toThrow();
|
||||
expect(readCollapsed("t", () => undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable groupBy:渲染", () => {
|
||||
it("每组一个可折叠组头,带计数;首屏全部展开", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(DataTable<Row>, {
|
||||
columns: [{ key: "name", header: "名称", cell: (r: Row) => r.name }],
|
||||
rows,
|
||||
rowKey: (r: Row) => r.id,
|
||||
groupBy: { key: (r: Row) => r.status },
|
||||
}),
|
||||
);
|
||||
expect(html.match(/aria-expanded="true"/g)).toHaveLength(2);
|
||||
expect(html).toContain("待识别");
|
||||
expect(html).toMatch(/待识别<\/span><span[^>]*>2<\/span>/);
|
||||
expect(html).toMatch(/已登记<\/span><span[^>]*>1<\/span>/);
|
||||
for (const n of ["a.mkv", "b.mkv", "c.mkv"]) expect(html).toContain(n);
|
||||
});
|
||||
it("不给 groupBy 时没有组头", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(DataTable<Row>, {
|
||||
columns: [{ key: "name", header: "名称", cell: (r: Row) => r.name }],
|
||||
rows,
|
||||
rowKey: (r: Row) => r.id,
|
||||
}),
|
||||
);
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user