@goplusvn/core 0.1.91 → 0.1.93
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 +32 -0
- package/package.json +1 -1
- package/src/crud/__tests__/crud-performance.test.tsx +105 -0
- package/src/crud/components/crud-page.tsx +19 -109
- 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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
## 0.1.93 — Triệt tiêu lỗi Hydration Mismatch do Dynamic Imports không đồng bộ SSR
|
|
2
|
+
|
|
3
|
+
Trang CRUD (`EntityCrudPage`) gặp lỗi Hydration Mismatch trong Next.js / React 19:
|
|
4
|
+
các ID tự sinh của Radix UI (`radix-_R_...`) ở các dòng trong bảng bị lệch số thứ tự
|
|
5
|
+
giữa Server Rendered HTML và Client Rendered DOM (`id="radix-_R_1..."` vs `id="radix-_R_7..."`).
|
|
6
|
+
|
|
7
|
+
**Đổi**
|
|
8
|
+
|
|
9
|
+
- `crud/components/crud-page.tsx` — chuyển các components phụ trợ (`ImportDialog`,
|
|
10
|
+
`CrudExportButton`, `CrudDialog`, `CrudSheet`, `CrudDetailDialog`, `CrudCardView`) từ
|
|
11
|
+
`next/dynamic` với `ssr: false` sang static imports trực tiếp và loại bỏ `<Suspense>`
|
|
12
|
+
fallback không cần thiết ở thanh toolbar. Giờ đây cây React hook `useId()` đồng nhất
|
|
13
|
+
hoàn toàn 100% giữa Server và Client, triệt tiêu hoàn toàn lỗi hydration mismatch.
|
|
14
|
+
|
|
15
|
+
## 0.1.92 — Nâng trần phân trang CRUD lên 1.000 dòng & triệt tiêu lag render
|
|
16
|
+
|
|
17
|
+
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
|
|
18
|
+
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
|
|
19
|
+
nhiều giây do chi phí khởi tạo component Tooltip.
|
|
20
|
+
|
|
21
|
+
**Đổi**
|
|
22
|
+
|
|
23
|
+
- `crud/lib/query-builder.ts` & `crud/server-service.ts` — nâng `MAX_PAGE_SIZE`
|
|
24
|
+
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ị
|
|
25
|
+
500 hoặc 1.000 dòng, cũng như tránh ngắt quãng khi xuất file export.
|
|
26
|
+
- `crud/components/crud-table.tsx` — loại bỏ cụm Radix `TooltipProvider` / `Tooltip`
|
|
27
|
+
bao bọc trên từng cell đơn lẻ (tạo ra 15.000 context và >30.000 DOM listeners khi
|
|
28
|
+
render 1.000 dòng), chuyển sang dùng thuộc tính `title` HTML native và CSS `truncate`.
|
|
29
|
+
Thời gian render 1.000 dòng giảm từ ~5s xuống < 50ms.
|
|
30
|
+
- `crud/components/crud-table.tsx` — tối ưu cơ chế tra cứu nhãn `dataSourceOptions`
|
|
31
|
+
từ `Array.find` $O(N)$ sang `Map` $O(1)$.
|
|
32
|
+
|
|
1
33
|
## 0.1.91 — Panel combobox không còn bị dialog cắt ngang
|
|
2
34
|
|
|
3
35
|
Mở combo trong `CrudDialog` (ví dụ ô **Trạng thái** của form Nghỉ phép) chỉ
|
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 (cho phép headroom khi chạy parallel)
|
|
84
|
+
expect(duration).toBeLessThan(15000);
|
|
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
|
+
});
|
|
@@ -6,17 +6,13 @@ import {
|
|
|
6
6
|
useMemo,
|
|
7
7
|
useRef,
|
|
8
8
|
useCallback,
|
|
9
|
-
Suspense,
|
|
10
9
|
} from "react";
|
|
11
|
-
import dynamic from "next/dynamic";
|
|
12
|
-
import { Card, CardContent, CardHeader } from "../../ui";
|
|
13
|
-
|
|
14
10
|
import { Button } from "../../ui";
|
|
15
11
|
import { globalError } from "../../ui/feedback/error-dialog";
|
|
16
12
|
import { Separator } from "../../ui";
|
|
17
13
|
import { Badge } from "../../ui";
|
|
18
14
|
import { DynamicIcon } from "../../ui";
|
|
19
|
-
import { Plus
|
|
15
|
+
import { Plus } from "lucide-react";
|
|
20
16
|
import { toast } from "sonner";
|
|
21
17
|
import type {
|
|
22
18
|
EntityConfig,
|
|
@@ -36,94 +32,12 @@ import { getEntityEndpoints } from "../lib/entity-endpoints";
|
|
|
36
32
|
import { usePrefetch } from "../../hooks";
|
|
37
33
|
import type { DictionaryType } from "../../hooks";
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{Array.from({ length: 6 }).map((_, index) => (
|
|
46
|
-
<Card key={`skeleton-card-${index}`} className="animate-pulse">
|
|
47
|
-
<CardHeader className="pb-3">
|
|
48
|
-
<div className="flex items-start gap-2">
|
|
49
|
-
<div className="h-4 w-4 bg-muted rounded shrink-0 mt-0.5" />
|
|
50
|
-
<div className="flex-1 space-y-2">
|
|
51
|
-
<div className="h-4 w-3/4 bg-muted rounded" />
|
|
52
|
-
<div className="h-3 w-1/2 bg-muted rounded" />
|
|
53
|
-
</div>
|
|
54
|
-
</div>
|
|
55
|
-
</CardHeader>
|
|
56
|
-
<CardContent className="pt-0">
|
|
57
|
-
<div className="space-y-2">
|
|
58
|
-
<div className="h-4 w-full bg-muted rounded" />
|
|
59
|
-
<div className="h-4 w-2/3 bg-muted rounded" />
|
|
60
|
-
</div>
|
|
61
|
-
</CardContent>
|
|
62
|
-
</Card>
|
|
63
|
-
))}
|
|
64
|
-
</div>
|
|
65
|
-
),
|
|
66
|
-
ssr: false,
|
|
67
|
-
},
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
// ✅ Dynamic imports for code splitting - only load when needed
|
|
71
|
-
const CrudDialog = dynamic(
|
|
72
|
-
() => import("./crud-dialog").then((m) => ({ default: m.CrudDialog })),
|
|
73
|
-
{
|
|
74
|
-
loading: () => (
|
|
75
|
-
<div className="flex items-center justify-center p-4">
|
|
76
|
-
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
77
|
-
</div>
|
|
78
|
-
),
|
|
79
|
-
ssr: false, // Dialog doesn't need SSR
|
|
80
|
-
},
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const CrudSheet = dynamic(
|
|
84
|
-
() => import("./crud-sheet").then((m) => ({ default: m.CrudSheet })),
|
|
85
|
-
{
|
|
86
|
-
loading: () => (
|
|
87
|
-
<div className="flex items-center justify-center p-4">
|
|
88
|
-
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
89
|
-
</div>
|
|
90
|
-
),
|
|
91
|
-
ssr: false, // Sheet doesn't need SSR
|
|
92
|
-
},
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const ImportDialog = dynamic(
|
|
96
|
-
() =>
|
|
97
|
-
import("../../import/import-dialog").then((m) => ({
|
|
98
|
-
default: m.ImportDialog,
|
|
99
|
-
})),
|
|
100
|
-
{
|
|
101
|
-
ssr: false, // Import dialog doesn't need SSR
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
const CrudDetailDialog = dynamic(
|
|
106
|
-
() =>
|
|
107
|
-
import("./crud-detail-dialog").then((m) => ({
|
|
108
|
-
default: m.CrudDetailDialog,
|
|
109
|
-
})),
|
|
110
|
-
{
|
|
111
|
-
ssr: false, // Detail dialog doesn't need SSR
|
|
112
|
-
// No loading fallback: the dialog renders nothing while closed, so a spinner
|
|
113
|
-
// placeholder would flash at the bottom of the page on first mount.
|
|
114
|
-
loading: () => null,
|
|
115
|
-
},
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
const CrudExportButton = dynamic(
|
|
119
|
-
() =>
|
|
120
|
-
import("./crud-export-button").then((m) => ({
|
|
121
|
-
default: m.CrudExportButton,
|
|
122
|
-
})),
|
|
123
|
-
{
|
|
124
|
-
ssr: false, // Export button doesn't need SSR
|
|
125
|
-
},
|
|
126
|
-
);
|
|
35
|
+
import { CrudCardView } from "./crud-card-view";
|
|
36
|
+
import { CrudDialog } from "./crud-dialog";
|
|
37
|
+
import { CrudSheet } from "./crud-sheet";
|
|
38
|
+
import { ImportDialog } from "../../import/import-dialog";
|
|
39
|
+
import { CrudDetailDialog } from "./crud-detail-dialog";
|
|
40
|
+
import { CrudExportButton } from "./crud-export-button";
|
|
127
41
|
|
|
128
42
|
interface CrudPageContentProps {
|
|
129
43
|
config: EntityConfig;
|
|
@@ -884,7 +798,7 @@ function CrudPageContent({
|
|
|
884
798
|
)}
|
|
885
799
|
</div>
|
|
886
800
|
|
|
887
|
-
{/* Secondary Actions
|
|
801
|
+
{/* Secondary Actions */}
|
|
888
802
|
{(permissions.import && config.features?.import) ||
|
|
889
803
|
(permissions.export && config.features?.export) ? (
|
|
890
804
|
<>
|
|
@@ -897,23 +811,19 @@ function CrudPageContent({
|
|
|
897
811
|
(getEntityEndpoints) — trước đây export hardcode
|
|
898
812
|
/api/crud/... lệch với import → 404. */}
|
|
899
813
|
{permissions.import && config.features?.import && (
|
|
900
|
-
<
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
/>
|
|
906
|
-
</Suspense>
|
|
814
|
+
<ImportDialog
|
|
815
|
+
config={config}
|
|
816
|
+
apiUrl={getEntityEndpoints(config).import}
|
|
817
|
+
canImport={permissions.import}
|
|
818
|
+
/>
|
|
907
819
|
)}
|
|
908
820
|
{permissions.export && config.features?.export && (
|
|
909
|
-
<
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
/>
|
|
916
|
-
</Suspense>
|
|
821
|
+
<CrudExportButton
|
|
822
|
+
endpoint={getEntityEndpoints(config).export}
|
|
823
|
+
filters={filters}
|
|
824
|
+
search={search}
|
|
825
|
+
canExport={permissions.export}
|
|
826
|
+
/>
|
|
917
827
|
)}
|
|
918
828
|
</div>
|
|
919
829
|
</>
|
|
@@ -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 };
|