提交 2840a7ad authored 作者: 王鹏飞's avatar 王鹏飞

feat(admin): add controlled AppList component

上级 f6c89058
.app-list {
display: flex;
flex-direction: column;
gap: 16px;
&__filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
&__filter-items {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: flex-end;
gap: 16px;
}
&__filter-item {
display: flex;
flex-direction: column;
gap: 8px;
}
&__filter-label {
color: rgb(0 0 0 / 88%);
}
&__toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__blocking-error {
padding: 48px 24px;
}
}
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button, Input, Switch } from "antd";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppList } from "./AppList";
import type { AppListFilterItem } from "./types";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const getComputedStyle = window.getComputedStyle;
Object.defineProperty(window, "getComputedStyle", {
value: (element: Element) => getComputedStyle(element),
});
afterEach(cleanup);
type Book = {
code: string;
title: string;
};
type Filters = {
keyword: string;
count: number;
enabled: boolean;
};
const columns = [
{
title: "书名",
dataIndex: "title",
},
];
const rows: Book[] = [{ code: "book-1", title: "受控组件设计" }];
function renderList(overrides: Record<string, unknown> = {}) {
return render(
<AppList<Book, Filters>
rowKey="code"
columns={columns}
data={{ rows, total: 41, loading: false }}
{...overrides}
/>,
);
}
describe("AppList", () => {
it("renders rows and columns with the explicit rowKey", () => {
const { container } = renderList();
expect(screen.getByRole("columnheader", { name: "书名" })).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "受控组件设计" })).toBeInTheDocument();
expect(container.querySelector('tr[data-row-key="book-1"]')).toBeInTheDocument();
});
it("renders controlled filter values and only reports changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const items: AppListFilterItem<Filters>[] = [
{
name: "keyword",
label: "关键词",
render: (field) => (
<Input
aria-label="关键词"
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
),
},
];
renderList({
filters: {
value: { keyword: "原值", count: 0, enabled: false },
items,
onChange,
onSearch: vi.fn(),
onReset: vi.fn(),
onRefresh: vi.fn(),
},
});
const input = screen.getByRole("textbox", { name: "关键词" });
expect(input).toHaveValue("原值");
await user.clear(input);
expect(onChange).toHaveBeenLastCalledWith({ keyword: "", count: 0, enabled: false });
expect(input).toHaveValue("原值");
});
it("reports search, reset, and refresh without doing work itself", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
const onReset = vi.fn();
const onRefresh = vi.fn();
renderList({
filters: {
value: { keyword: "", count: 0, enabled: false },
items: [],
onChange: vi.fn(),
onSearch,
onReset,
onRefresh,
},
});
await user.click(screen.getByRole("button", { name: /搜\s*索/ }));
await user.click(screen.getByRole("button", { name: /重\s*置/ }));
await user.click(screen.getByRole("button", { name: /刷\s*新/ }));
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onReset).toHaveBeenCalledTimes(1);
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("uses controlled page, pageSize, and total and reports pagination changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onPageSizeChange = vi.fn();
renderList({
pagination: {
page: 2,
pageSize: 10,
pageSizeOptions: [10, 20, 30, 40],
onChange,
onPageSizeChange,
},
});
expect(screen.getByText("共 41 条")).toBeInTheDocument();
expect(screen.getByTitle("2")).toHaveClass("ant-pagination-item-active");
await user.click(screen.getByTitle("3"));
expect(onChange).toHaveBeenCalledWith(3);
expect(screen.getByTitle("2")).toHaveClass("ant-pagination-item-active");
const sizeSelector = screen.getByRole("combobox", { name: "Page Size" });
fireEvent.mouseDown(sizeSelector);
await user.click(await screen.findByRole("option", { name: "20 / page" }));
expect(onPageSizeChange).toHaveBeenCalledWith(20);
expect(sizeSelector.closest(".ant-select")).toHaveTextContent("10 / page");
});
it("prioritizes loading, then renders retryable error and empty states", async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
const { container, rerender } = render(
<AppList<Book, Filters>
rowKey="code"
columns={columns}
data={{ rows: [], total: 0, loading: true, error: new Error("网络异常") }}
onRetry={onRetry}
/>,
);
expect(container.querySelector(".ant-spin-spinning")).toBeInTheDocument();
expect(screen.queryByText("网络异常")).not.toBeInTheDocument();
rerender(
<AppList<Book, Filters>
rowKey="code"
columns={columns}
data={{ rows: [], total: 0, loading: false, error: new Error("网络异常") }}
onRetry={onRetry}
/>,
);
expect(screen.getByText("网络异常")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /重\s*试/ }));
expect(onRetry).toHaveBeenCalledTimes(1);
rerender(
<AppList<Book, Filters>
rowKey="code"
columns={columns}
data={{ rows: [], total: 0, loading: false }}
/>,
);
expect(screen.getByText("暂无数据")).toBeInTheDocument();
});
it("keeps existing rows visible when a refresh fails", () => {
renderList({
data: { rows, total: 1, loading: false, error: new Error("刷新失败") },
onRetry: vi.fn(),
});
expect(screen.getByRole("alert")).toHaveTextContent("刷新失败");
expect(screen.getByRole("cell", { name: "受控组件设计" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /重\s*试/ })).toBeInTheDocument();
});
it("renders toolbar without filters and forwards rowSelection", async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
renderList({
toolbar: <Button>新增图书</Button>,
selection: { onChange: onSelectionChange },
});
expect(screen.getByRole("button", { name: "新增图书" })).toBeInTheDocument();
const checkboxes = screen.getAllByRole("checkbox");
await user.click(checkboxes[1]);
expect(onSelectionChange).toHaveBeenCalledWith(["book-1"], rows, { type: "single" });
});
it("preserves controlled 0 and false values when another filter changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const items: AppListFilterItem<Filters>[] = [
{
name: "keyword",
label: "关键词",
render: (field) => <Button onClick={() => field.onChange("新值")}>修改关键词</Button>,
},
{
name: "count",
label: "数量",
render: (field) => <span data-testid="count-value">{field.value}</span>,
},
{
name: "enabled",
label: "启用",
render: (field) => (
<Switch aria-label="启用" checked={field.value} onChange={field.onChange} />
),
},
];
renderList({
filters: {
value: { keyword: "", count: 0, enabled: false },
items,
onChange,
onSearch: vi.fn(),
onReset: vi.fn(),
onRefresh: vi.fn(),
},
});
expect(screen.getByTestId("count-value")).toHaveTextContent("0");
expect(screen.getByRole("switch", { name: "启用" })).not.toBeChecked();
await user.click(screen.getByRole("button", { name: "修改关键词" }));
expect(onChange).toHaveBeenCalledWith({ keyword: "新值", count: 0, enabled: false });
});
it("does not let tableProps override controlled table data", () => {
renderList({
tableProps: {
columns: [{ title: "错误列", dataIndex: "title" }],
dataSource: [{ code: "wrong", title: "错误数据" }],
loading: false,
pagination: false,
rowKey: "wrongKey",
},
});
const table = screen.getByRole("table");
expect(within(table).getByText("受控组件设计")).toBeInTheDocument();
expect(within(table).queryByText("错误数据")).not.toBeInTheDocument();
expect(within(table).queryByText("错误列")).not.toBeInTheDocument();
});
});
import { Alert, Button, Empty, Space, Table } from "antd";
import type { TablePaginationConfig } from "antd/es/table";
import type { AppListFilterItem, AppListProps } from "./types";
import "./AppList.less";
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 30, 40];
export function AppList<Row extends object, Filters extends object = Record<string, never>>({
rowKey,
columns,
data,
filters,
pagination,
selection,
toolbar,
onRetry,
tableProps,
}: AppListProps<Row, Filters>) {
const hasBlockingError = !data.loading && data.error && data.rows.length === 0;
const hasRefreshError = !data.loading && data.error && data.rows.length > 0;
const tablePagination: false | TablePaginationConfig = pagination
? {
current: pagination.page,
pageSize: pagination.pageSize,
total: data.total,
pageSizeOptions: pagination.pageSizeOptions ?? DEFAULT_PAGE_SIZE_OPTIONS,
showQuickJumper: true,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
onChange: (page, pageSize) => {
if (pageSize !== pagination.pageSize) {
pagination.onPageSizeChange(pageSize);
return;
}
pagination.onChange(page);
},
}
: false;
const updateFilter = <Name extends keyof Filters>(
item: AppListFilterItem<Filters> & { name: Name },
value: Filters[Name],
) => {
filters?.onChange({ ...filters.value, [item.name]: value });
};
return (
<div className="app-list">
{filters ? (
<section className="app-list__filters" aria-label="筛选条件">
<div className="app-list__filter-items">
{filters.items.map((item) => (
<div className="app-list__filter-item" key={String(item.name)}>
<span className="app-list__filter-label">{item.label}</span>
{item.render({
value: filters.value[item.name],
onChange: (value) => updateFilter(item, value),
})}
</div>
))}
</div>
<Space wrap>
<Button type="primary" onClick={filters.onSearch}>
搜索
</Button>
<Button onClick={filters.onReset}>重置</Button>
<Button onClick={filters.onRefresh}>刷新</Button>
</Space>
</section>
) : null}
{toolbar ? <div className="app-list__toolbar">{toolbar}</div> : null}
{hasRefreshError ? (
<Alert
className="app-list__error"
type="error"
showIcon
message={data.error?.message}
action={
onRetry ? (
<Button size="small" danger onClick={onRetry}>
重试
</Button>
) : null
}
/>
) : null}
{hasBlockingError ? (
<div className="app-list__blocking-error" role="alert">
<Empty description={data.error?.message}>
{onRetry ? (
<Button type="primary" danger onClick={onRetry}>
重试
</Button>
) : null}
</Empty>
</div>
) : (
<Table<Row>
{...tableProps}
rowKey={rowKey}
columns={columns}
dataSource={data.rows}
loading={data.loading}
pagination={tablePagination}
rowSelection={selection}
locale={{ ...tableProps?.locale, emptyText: <Empty description="暂无数据" /> }}
/>
)}
</div>
);
}
export { AppList } from "./AppList";
export type { AppListFilterField, AppListFilterItem, AppListProps } from "./types";
import type { ColumnsType, TableProps } from "antd/es/table";
import type { TableRowSelection } from "antd/es/table/interface";
import type React from "react";
export interface AppListFilterField<Value> {
value: Value;
onChange: (next: Value) => void;
}
export type AppListFilterItem<Filters extends object> = {
[Name in keyof Filters]: {
name: Name;
label: React.ReactNode;
render: (field: AppListFilterField<Filters[Name]>) => React.ReactNode;
};
}[keyof Filters];
export interface AppListProps<Row extends object, Filters extends object = Record<string, never>> {
rowKey: string | ((row: Row) => React.Key);
columns: ColumnsType<Row>;
data: {
rows: Row[];
total: number;
loading: boolean;
error?: Error | null;
};
filters?: {
value: Filters;
items: AppListFilterItem<Filters>[];
onChange: (next: Filters) => void;
onSearch: () => void;
onReset: () => void;
onRefresh: () => void;
};
pagination?:
| false
| {
page: number;
pageSize: number;
pageSizeOptions?: number[];
onChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
};
selection?: TableRowSelection<Row>;
toolbar?: React.ReactNode;
onRetry?: () => void;
tableProps?: Omit<
TableProps<Row>,
"rowKey" | "columns" | "dataSource" | "loading" | "pagination" | "rowSelection"
>;
}
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论