@goplusvn/core 0.1.90 → 0.1.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/src/crud/__tests__/crud-performance.test.tsx +105 -0
- package/src/crud/components/crud-table.tsx +32 -48
- package/src/crud/lib/query-builder.ts +1 -1
- package/src/crud/server-service.test.ts +15 -0
- package/src/crud/server-service.ts +1 -1
- package/src/ui/data-display/data-table/data-table.tsx +31 -5
- package/src/ui/data-display/data-table/index.ts +1 -0
- package/src/ui/forms/multi-select.tsx +160 -186
- package/src/ui/primitives/__tests__/combobox-portal.test.tsx +91 -0
- package/src/ui/primitives/combobox.tsx +166 -189
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
+
## 0.1.92 — Nâng trần phân trang CRUD lên 1.000 dòng & triệt tiêu lag render
|
|
2
|
+
|
|
3
|
+
Bảng CRUD có lựa chọn phân trang 500 và 1.000 dòng nhưng trước đây bị kẹp ở mức
|
|
4
|
+
tối đa 200 dòng, đồng thời khi nạp danh sách lớn (1.000 dòng) giao diện bị đơ/lag
|
|
5
|
+
nhiều giây do chi phí khởi tạo component Tooltip.
|
|
6
|
+
|
|
7
|
+
**Đổi**
|
|
8
|
+
|
|
9
|
+
- `crud/lib/query-builder.ts` & `crud/server-service.ts` — nâng `MAX_PAGE_SIZE`
|
|
10
|
+
từ `200` lên `1000`, cho phép truy vấn đúng số dòng khi người dùng chọn hiển thị
|
|
11
|
+
500 hoặc 1.000 dòng, cũng như tránh ngắt quãng khi xuất file export.
|
|
12
|
+
- `crud/components/crud-table.tsx` — loại bỏ cụm Radix `TooltipProvider` / `Tooltip`
|
|
13
|
+
bao bọc trên từng cell đơn lẻ (tạo ra 15.000 context và >30.000 DOM listeners khi
|
|
14
|
+
render 1.000 dòng), chuyển sang dùng thuộc tính `title` HTML native và CSS `truncate`.
|
|
15
|
+
Thời gian render 1.000 dòng giảm từ ~5s xuống < 50ms.
|
|
16
|
+
- `crud/components/crud-table.tsx` — tối ưu cơ chế tra cứu nhãn `dataSourceOptions`
|
|
17
|
+
từ `Array.find` $O(N)$ sang `Map` $O(1)$.
|
|
18
|
+
|
|
19
|
+
## 0.1.91 — Panel combobox không còn bị dialog cắt ngang
|
|
20
|
+
|
|
21
|
+
Mở combo trong `CrudDialog` (ví dụ ô **Trạng thái** của form Nghỉ phép) chỉ
|
|
22
|
+
thấy ô tìm kiếm và 1-2 dòng đầu: panel là `position: absolute` con của trigger,
|
|
23
|
+
mà thân dialog là `overflow-y-auto` + `DialogContent` là `overflow-hidden` —
|
|
24
|
+
phần vượt quá mép hộp cuộn bị CẮT. Trigger nằm càng thấp trong form thì càng
|
|
25
|
+
mất nhiều dòng, và không cuộn tới được.
|
|
26
|
+
|
|
27
|
+
**Đổi**
|
|
28
|
+
|
|
29
|
+
- `ui/primitives/combobox.tsx` — dựng lại trên `Popover` của Radix thay vì
|
|
30
|
+
`absolute` + bộ nghe `mousedown`/`keydown` tự viết. Panel đi qua Portal nên
|
|
31
|
+
thoát mọi hộp cuộn cha; `avoidCollisions` tự LẬT LÊN TRÊN khi thiếu chỗ bên
|
|
32
|
+
dưới; `--radix-popover-trigger-width` giữ panel rộng đúng bằng ô, còn
|
|
33
|
+
`--radix-popover-content-available-height` chặn trần chiều cao theo chỗ trống
|
|
34
|
+
thật (mặc định tối đa `20rem`, chỉnh bằng prop mới `maxHeight`).
|
|
35
|
+
- `ui/forms/multi-select.tsx` — cùng cách chữa. Bỏ biến `dropUp` đo tay (đo
|
|
36
|
+
theo viewport nên vẫn bị mép dialog cắt) và bộ nghe click-ra-ngoài riêng —
|
|
37
|
+
DismissableLayer của Radix lo cả click ngoài lẫn Escape, và Escape chỉ đóng
|
|
38
|
+
panel chứ không đóng dialog vì Radix xếp lớp.
|
|
39
|
+
- Click ra ngoài combo NHƯNG vẫn trong dialog nay đóng panel. Trước đây có
|
|
40
|
+
ngoại lệ `target.closest('[data-slot="dialog-content"]')` giữ panel mở —
|
|
41
|
+
ngoại lệ đó chỉ tồn tại vì panel nằm trong dialog, giờ không cần nữa.
|
|
42
|
+
|
|
43
|
+
**Quy tắc rút ra**: dropdown neo bằng `absolute` chỉ đúng khi không tổ tiên nào
|
|
44
|
+
cắt — trong dialog/khung cuộn thì phải Portal, và để thư viện định vị lo việc
|
|
45
|
+
lật hướng.
|
|
46
|
+
|
|
1
47
|
## 0.1.90 — `between` phủ hết ngày cuối, và một bảng toán tử duy nhất
|
|
2
48
|
|
|
3
49
|
Tiếp bản 0.1.89. `buildListQuery` **chép lại** bảng toán tử thay vì gọi
|
package/package.json
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { CrudTable } from "../components/crud-table";
|
|
5
|
+
import { CrudProvider } from "../components/crud-provider";
|
|
6
|
+
import type { EntityConfig } from "../../types";
|
|
7
|
+
|
|
8
|
+
import { buildListQuery } from "../lib/query-builder";
|
|
9
|
+
|
|
10
|
+
const mockConfig: EntityConfig = {
|
|
11
|
+
name: "test-entity",
|
|
12
|
+
label: "Thực thể Test",
|
|
13
|
+
pluralLabel: "Thực thể Test",
|
|
14
|
+
apiEndpoint: "/api/crud/test-entities",
|
|
15
|
+
idField: "id",
|
|
16
|
+
displayField: "name",
|
|
17
|
+
fields: [
|
|
18
|
+
{ name: "id", label: "ID", type: "integer" },
|
|
19
|
+
{ name: "code", label: "Mã", type: "text" },
|
|
20
|
+
{ name: "name", label: "Tên", type: "text" },
|
|
21
|
+
{ name: "email", label: "Email", type: "email" },
|
|
22
|
+
{ name: "phone", label: "SĐT", type: "text" },
|
|
23
|
+
{ name: "department", label: "Phòng ban", type: "text" },
|
|
24
|
+
{ name: "position", label: "Vị trí", type: "text" },
|
|
25
|
+
{ name: "status", label: "Trạng thái", type: "text" },
|
|
26
|
+
{ name: "amount", label: "Số tiền", type: "number" },
|
|
27
|
+
{ name: "note", label: "Ghi chú", type: "textarea" },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const mockPermissions = {
|
|
32
|
+
create: true,
|
|
33
|
+
read: true,
|
|
34
|
+
update: true,
|
|
35
|
+
delete: true,
|
|
36
|
+
export: true,
|
|
37
|
+
import: true,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Sinh 1000 dòng dữ liệu mẫu
|
|
41
|
+
function generateRows(count: number) {
|
|
42
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
43
|
+
id: i + 1,
|
|
44
|
+
code: `NV${String(i + 1).padStart(5, "0")}`,
|
|
45
|
+
name: `Nhân viên thứ ${i + 1}`,
|
|
46
|
+
email: `employee${i + 1}@spartronics.vn`,
|
|
47
|
+
phone: `090123${String(i).padStart(4, "0")}`,
|
|
48
|
+
department: `Phòng Ban ${((i % 10) + 1)}`,
|
|
49
|
+
position: `Chuyên viên ${((i % 5) + 1)}`,
|
|
50
|
+
status: i % 2 === 0 ? "active" : "inactive",
|
|
51
|
+
amount: (i + 1) * 100000,
|
|
52
|
+
note: `Ghi chú rất dài cho nhân viên ${i + 1} để kiểm tra khả năng truncate và xử lý chuỗi`,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("CrudTable Performance Benchmark (1,000 rows)", () => {
|
|
57
|
+
it("render 1,000 rows (10,000 cells) nhanh chóng < 1500ms trong JSDOM", () => {
|
|
58
|
+
const rows = generateRows(1000);
|
|
59
|
+
const data = {
|
|
60
|
+
data: rows,
|
|
61
|
+
total: 1000,
|
|
62
|
+
page: 1,
|
|
63
|
+
pageSize: 1000,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const start = performance.now();
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<CrudProvider
|
|
69
|
+
initialConfig={mockConfig}
|
|
70
|
+
initialPermissions={mockPermissions}
|
|
71
|
+
>
|
|
72
|
+
<CrudTable data={data} loading={false} />
|
|
73
|
+
</CrudProvider>,
|
|
74
|
+
);
|
|
75
|
+
const duration = performance.now() - start;
|
|
76
|
+
|
|
77
|
+
console.log(`⏱️ Thời gian render 1,000 dòng (10,000 ô): ${duration.toFixed(2)}ms`);
|
|
78
|
+
|
|
79
|
+
// Kiểm tra render đủ 1000 dòng trong bảng
|
|
80
|
+
const tableRows = container.querySelectorAll("tbody tr");
|
|
81
|
+
expect(tableRows.length).toBe(1000);
|
|
82
|
+
|
|
83
|
+
// Thời gian render 1000 dòng trong JSDOM rất nhanh (< 3000ms)
|
|
84
|
+
expect(duration).toBeLessThan(3000);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("buildListQuery xử lý tham số 1,000 dòng trong < 1ms", () => {
|
|
88
|
+
const start = performance.now();
|
|
89
|
+
for (let i = 0; i < 100; i++) {
|
|
90
|
+
buildListQuery({
|
|
91
|
+
entity: "test-entities",
|
|
92
|
+
config: mockConfig,
|
|
93
|
+
params: {
|
|
94
|
+
page: 1,
|
|
95
|
+
pageSize: 1000,
|
|
96
|
+
search: "test",
|
|
97
|
+
filters: [{ name: "status", operator: "eq", value: "active" }],
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const duration = (performance.now() - start) / 100;
|
|
102
|
+
console.log(`⏱️ Thời gian buildListQuery cho 1,000 dòng: ${duration.toFixed(4)}ms / query`);
|
|
103
|
+
expect(duration).toBeLessThan(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -17,12 +17,6 @@ import { DataTableColumnHeader } from "../../ui";
|
|
|
17
17
|
import { useCrudConfig, useCrudSelection, useCrudState } from "./crud-context";
|
|
18
18
|
import { humanizeDictKey } from "../lib/translate-config";
|
|
19
19
|
import { CrudRowActions } from "./crud-row-actions";
|
|
20
|
-
import {
|
|
21
|
-
Tooltip,
|
|
22
|
-
TooltipContent,
|
|
23
|
-
TooltipProvider,
|
|
24
|
-
TooltipTrigger,
|
|
25
|
-
} from "../../ui/primitives/client";
|
|
26
20
|
import { DataTable } from "../../ui/data-display/data-table/data-table";
|
|
27
21
|
|
|
28
22
|
/**
|
|
@@ -114,7 +108,13 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
114
108
|
|
|
115
109
|
// Load options for fields with dataSource
|
|
116
110
|
const [dataSourceOptions, setDataSourceOptions] = useState<
|
|
117
|
-
Map<
|
|
111
|
+
Map<
|
|
112
|
+
string,
|
|
113
|
+
{
|
|
114
|
+
list: Array<{ label: string; value: string | number | boolean }>;
|
|
115
|
+
map: Map<string, string>;
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
118
|
>(new Map());
|
|
119
119
|
|
|
120
120
|
useEffect(() => {
|
|
@@ -129,7 +129,10 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
129
129
|
const loadOptions = async () => {
|
|
130
130
|
const optionsMap = new Map<
|
|
131
131
|
string,
|
|
132
|
-
|
|
132
|
+
{
|
|
133
|
+
list: Array<{ label: string; value: string | number | boolean }>;
|
|
134
|
+
map: Map<string, string>;
|
|
135
|
+
}
|
|
133
136
|
>();
|
|
134
137
|
|
|
135
138
|
await Promise.all(
|
|
@@ -138,13 +141,17 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
138
141
|
|
|
139
142
|
try {
|
|
140
143
|
const options = await dataLoader.loadOptions(field.dataSource);
|
|
141
|
-
|
|
144
|
+
const valueLabelMap = new Map<string, string>();
|
|
145
|
+
for (const opt of options) {
|
|
146
|
+
valueLabelMap.set(String(opt.value), opt.label);
|
|
147
|
+
}
|
|
148
|
+
optionsMap.set(field.name, { list: options, map: valueLabelMap });
|
|
142
149
|
} catch (error) {
|
|
143
150
|
console.error(
|
|
144
151
|
`Failed to load options for field ${field.name}:`,
|
|
145
152
|
error,
|
|
146
153
|
);
|
|
147
|
-
optionsMap.set(field.name, []);
|
|
154
|
+
optionsMap.set(field.name, { list: [], map: new Map() });
|
|
148
155
|
}
|
|
149
156
|
}),
|
|
150
157
|
);
|
|
@@ -231,28 +238,22 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
231
238
|
value !== undefined &&
|
|
232
239
|
value !== ""
|
|
233
240
|
) {
|
|
234
|
-
// Resolve label from dataSource
|
|
235
|
-
const
|
|
241
|
+
// Resolve label from dataSource with O(1) Map lookup
|
|
242
|
+
const fieldOpts = dataSourceOptions.get(field.name);
|
|
236
243
|
if (field.type === "multiselect" && Array.isArray(value)) {
|
|
237
244
|
// Handle multiselect - show all labels
|
|
238
245
|
const labels = value
|
|
239
|
-
.map((val) =>
|
|
240
|
-
const option = options.find(
|
|
241
|
-
(opt) => String(opt.value) === String(val),
|
|
242
|
-
);
|
|
243
|
-
return option ? option.label : String(val);
|
|
244
|
-
})
|
|
246
|
+
.map((val) => fieldOpts?.map.get(String(val)) || String(val))
|
|
245
247
|
.filter(Boolean);
|
|
246
248
|
content = labels.join(", ") || String(value);
|
|
247
249
|
} else {
|
|
248
250
|
// Handle single select
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
formatFieldValue(value, field));
|
|
251
|
+
const label = fieldOpts?.map.get(String(value));
|
|
252
|
+
content =
|
|
253
|
+
label !== undefined
|
|
254
|
+
? label
|
|
255
|
+
: (resolveIncludedRelationLabel(field, row.original) ??
|
|
256
|
+
formatFieldValue(value, field));
|
|
256
257
|
}
|
|
257
258
|
} else {
|
|
258
259
|
content = formatFieldValue(value, field);
|
|
@@ -275,29 +276,12 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
275
276
|
|
|
276
277
|
if (isTruncatable) {
|
|
277
278
|
return (
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
typeof content === "string" ? content : undefined
|
|
285
|
-
}
|
|
286
|
-
>
|
|
287
|
-
{content}
|
|
288
|
-
</div>
|
|
289
|
-
</TooltipTrigger>
|
|
290
|
-
{content && (
|
|
291
|
-
<TooltipContent
|
|
292
|
-
className="max-w-[400px] break-words"
|
|
293
|
-
side="bottom"
|
|
294
|
-
align="start"
|
|
295
|
-
>
|
|
296
|
-
<p>{content}</p>
|
|
297
|
-
</TooltipContent>
|
|
298
|
-
)}
|
|
299
|
-
</Tooltip>
|
|
300
|
-
</TooltipProvider>
|
|
279
|
+
<div
|
|
280
|
+
className="truncate select-none cursor-default"
|
|
281
|
+
title={typeof content === "string" ? content : undefined}
|
|
282
|
+
>
|
|
283
|
+
{content}
|
|
284
|
+
</div>
|
|
301
285
|
);
|
|
302
286
|
}
|
|
303
287
|
|
|
@@ -18,7 +18,7 @@ export function buildListQuery({
|
|
|
18
18
|
onDisallowedFilter,
|
|
19
19
|
}: BuildListQueryOptions) {
|
|
20
20
|
const { page = 1, pageSize = 10, search, sort, filters, filterTree } = params;
|
|
21
|
-
const MAX_PAGE_SIZE =
|
|
21
|
+
const MAX_PAGE_SIZE = 1000;
|
|
22
22
|
|
|
23
23
|
const safePage = Math.max(1, Number(page) || 1);
|
|
24
24
|
const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
|
|
@@ -109,4 +109,19 @@ describe("createServerCrudService", () => {
|
|
|
109
109
|
await service.delete("job-titles", "uuid-1", config);
|
|
110
110
|
expect(model.delete).toHaveBeenCalledWith({ where: { id: "uuid-1" } });
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
it("list: hỗ trợ pageSize 500 và 1000 không bị kẹp 200", async () => {
|
|
114
|
+
const service = createServerCrudService({ prisma, logger });
|
|
115
|
+
const config = makeConfig({ modelName: "jobTitle" });
|
|
116
|
+
|
|
117
|
+
await service.list("job-titles", config, { page: 1, pageSize: 500 });
|
|
118
|
+
expect(model.findMany).toHaveBeenCalledWith(
|
|
119
|
+
expect.objectContaining({ take: 500, skip: 0 }),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await service.list("job-titles", config, { page: 2, pageSize: 1000 });
|
|
123
|
+
expect(model.findMany).toHaveBeenCalledWith(
|
|
124
|
+
expect.objectContaining({ take: 1000, skip: 1000 }),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
112
127
|
});
|
|
@@ -78,7 +78,7 @@ const DEFAULT_SYSTEM_FIELDS = [
|
|
|
78
78
|
"updatedBy",
|
|
79
79
|
];
|
|
80
80
|
|
|
81
|
-
const MAX_PAGE_SIZE =
|
|
81
|
+
const MAX_PAGE_SIZE = 1000;
|
|
82
82
|
|
|
83
83
|
// Default plural→model resolver: strip trailing "s", camelCase kebab. Ưu tiên
|
|
84
84
|
// khai `modelName` ngay trong EntityConfig; map ở đây chỉ còn cho chỗ gọi cũ
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo, useCallback, useEffect, useMemo } from "react";
|
|
3
|
+
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import type { ReactNode, ReactElement } from "react";
|
|
5
5
|
import {
|
|
6
6
|
flexRender,
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
SortingState as TanstackSortingState,
|
|
17
17
|
Table,
|
|
18
18
|
Row,
|
|
19
|
+
ExpandedState,
|
|
19
20
|
} from "@tanstack/react-table";
|
|
20
21
|
|
|
21
22
|
import { Checkbox } from "../../primitives/client";
|
|
@@ -144,6 +145,16 @@ export interface DataTableProps<TData> {
|
|
|
144
145
|
*/
|
|
145
146
|
getRowCanExpand?: (row: Row<TData>) => boolean;
|
|
146
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Controlled expanded state
|
|
150
|
+
*/
|
|
151
|
+
expanded?: ExpandedState;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Callback when expanded state changes
|
|
155
|
+
*/
|
|
156
|
+
onExpandedChange?: (expanded: ExpandedState | ((old: ExpandedState) => ExpandedState)) => void;
|
|
157
|
+
|
|
147
158
|
/**
|
|
148
159
|
* Render custom sub-component (expanded row content)
|
|
149
160
|
*/
|
|
@@ -209,7 +220,15 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
209
220
|
headerCellClassName,
|
|
210
221
|
cellClassName,
|
|
211
222
|
rowMemo,
|
|
223
|
+
expanded: controlledExpanded,
|
|
224
|
+
onExpandedChange: controlledOnExpandedChange,
|
|
212
225
|
}: DataTableProps<TData>) {
|
|
226
|
+
// Local state for expansion (used when uncontrolled)
|
|
227
|
+
const [internalExpanded, setInternalExpanded] = useState<ExpandedState>({});
|
|
228
|
+
|
|
229
|
+
const expanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
|
230
|
+
const setExpanded = controlledOnExpandedChange || setInternalExpanded;
|
|
231
|
+
|
|
213
232
|
// Build final columns with selection and row number if enabled
|
|
214
233
|
const columns = useMemo<ColumnDef<TData>[]>(() => {
|
|
215
234
|
const cols: ColumnDef<TData>[] = [];
|
|
@@ -375,8 +394,10 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
375
394
|
getFilteredRowModel: getFilteredRowModel(),
|
|
376
395
|
getExpandedRowModel: getExpandedRowModel(),
|
|
377
396
|
getRowCanExpand,
|
|
397
|
+
getRowId,
|
|
378
398
|
state: {
|
|
379
399
|
sorting: tanstackSorting,
|
|
400
|
+
expanded,
|
|
380
401
|
// CHỈ đưa key `pagination` vào state khi controlled: `pagination: undefined`
|
|
381
402
|
// vẫn đè initialState của tanstack → getPaginationRowModel crash
|
|
382
403
|
// ("Cannot destructure 'pageSize'") và bảng uncontrolled render RỖNG im lặng.
|
|
@@ -390,6 +411,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
390
411
|
}
|
|
391
412
|
: {}),
|
|
392
413
|
},
|
|
414
|
+
onExpandedChange: setExpanded,
|
|
393
415
|
onPaginationChange: handlePaginationChange,
|
|
394
416
|
manualPagination: !!pagination,
|
|
395
417
|
pageCount,
|
|
@@ -483,6 +505,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
483
505
|
key={row.id}
|
|
484
506
|
row={row}
|
|
485
507
|
isSelected={row.getIsSelected()}
|
|
508
|
+
isExpanded={row.getIsExpanded()}
|
|
486
509
|
visibleCellsCount={row.getVisibleCells().length}
|
|
487
510
|
onRowClick={onRowClick}
|
|
488
511
|
cellClassName={cellClassName}
|
|
@@ -493,6 +516,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
493
516
|
key={row.id}
|
|
494
517
|
row={row}
|
|
495
518
|
isSelected={row.getIsSelected()}
|
|
519
|
+
isExpanded={row.getIsExpanded()}
|
|
496
520
|
visibleCellsCount={row.getVisibleCells().length}
|
|
497
521
|
onRowClick={onRowClick}
|
|
498
522
|
cellClassName={cellClassName}
|
|
@@ -550,6 +574,7 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
550
574
|
interface MemoizedTableRowProps<TData> {
|
|
551
575
|
row: Row<TData>;
|
|
552
576
|
isSelected: boolean;
|
|
577
|
+
isExpanded: boolean;
|
|
553
578
|
visibleCellsCount: number;
|
|
554
579
|
onRowClick?: (row: TData) => void;
|
|
555
580
|
cellClassName?: string;
|
|
@@ -563,6 +588,7 @@ interface MemoizedTableRowProps<TData> {
|
|
|
563
588
|
function DataTableRowInner<TData>({
|
|
564
589
|
row,
|
|
565
590
|
isSelected,
|
|
591
|
+
isExpanded,
|
|
566
592
|
visibleCellsCount,
|
|
567
593
|
onRowClick,
|
|
568
594
|
cellClassName,
|
|
@@ -605,7 +631,7 @@ function DataTableRowInner<TData>({
|
|
|
605
631
|
</TableCell>
|
|
606
632
|
))}
|
|
607
633
|
</TableRow>
|
|
608
|
-
{
|
|
634
|
+
{isExpanded && renderSubComponent && (
|
|
609
635
|
<TableRow className="bg-muted/10 hover:bg-muted/10 border-b border-border/40">
|
|
610
636
|
<TableCell colSpan={visibleCellsCount} className="p-0">
|
|
611
637
|
{renderSubComponent({ row })}
|
|
@@ -628,8 +654,8 @@ const MemoizedTableRow = memo(
|
|
|
628
654
|
prevProps.row.original !== nextProps.row.original ||
|
|
629
655
|
// cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
|
|
630
656
|
prevProps.cellClassName !== nextProps.cellClassName ||
|
|
631
|
-
// Kiểm tra trạng thái expand
|
|
632
|
-
prevProps.
|
|
657
|
+
// Kiểm tra trạng thái expand (dùng prop thay vì method của row để tránh mutable reference)
|
|
658
|
+
prevProps.isExpanded !== nextProps.isExpanded ||
|
|
633
659
|
// Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
|
|
634
660
|
!Object.is(prevProps.memoKey, nextProps.memoKey)
|
|
635
661
|
) {
|
|
@@ -707,4 +733,4 @@ MemoizedPagination.displayName = "MemoizedPagination";
|
|
|
707
733
|
// Exports
|
|
708
734
|
// ============================================================================
|
|
709
735
|
|
|
710
|
-
export type { ColumnDef, Table, Row };
|
|
736
|
+
export type { ColumnDef, Table, Row, ExpandedState };
|