@goplusvn/core 0.1.75 → 0.1.77
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 +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +301 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +173 -0
- package/src/workspace/pages/workspace-list-page.tsx +802 -0
- package/src/workspace/route-handlers.ts +550 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { configureWorkspaces, resetWorkspaceConfig } from "../scope";
|
|
4
|
+
import {
|
|
5
|
+
buildTree,
|
|
6
|
+
createWorkspace,
|
|
7
|
+
deactivateSubtree,
|
|
8
|
+
listSubtree,
|
|
9
|
+
moveWorkspace,
|
|
10
|
+
} from "../service";
|
|
11
|
+
import { WorkspaceTreeError } from "../tree";
|
|
12
|
+
import type { WorkspaceServiceDb } from "../service";
|
|
13
|
+
import type { WorkspaceNode } from "../types";
|
|
14
|
+
|
|
15
|
+
interface Row {
|
|
16
|
+
id: string;
|
|
17
|
+
code: string;
|
|
18
|
+
name: string;
|
|
19
|
+
kind: string;
|
|
20
|
+
parentId: string | null;
|
|
21
|
+
path: string;
|
|
22
|
+
depth: number;
|
|
23
|
+
isActive: boolean;
|
|
24
|
+
settings?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* DB giả trong bộ nhớ. Chỉ hiểu đúng những dạng `where` mà service thật sự phát
|
|
29
|
+
* ra (`id`, `parentId`, `path.startsWith`) — where lạ thì NÉM, để test không âm
|
|
30
|
+
* thầm đúng khi service đổi cách truy vấn.
|
|
31
|
+
*/
|
|
32
|
+
function fakeDb(seed: Row[] = []) {
|
|
33
|
+
const rows = new Map<string, Row>(seed.map((r) => [r.id, r]));
|
|
34
|
+
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
const match = (row: Row, where: any): boolean => {
|
|
37
|
+
if (!where) return true;
|
|
38
|
+
for (const [key, cond] of Object.entries(where)) {
|
|
39
|
+
if (key === "path" && cond && typeof cond === "object") {
|
|
40
|
+
const startsWith = (cond as { startsWith?: string }).startsWith;
|
|
41
|
+
if (startsWith === undefined) throw new Error("where.path lạ");
|
|
42
|
+
if (!row.path.startsWith(startsWith)) return false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (key !== "id" && key !== "parentId") {
|
|
46
|
+
throw new Error(`fakeDb chưa hiểu where.${key}`);
|
|
47
|
+
}
|
|
48
|
+
if ((row as unknown as Record<string, unknown>)[key] !== cond)
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const db: WorkspaceServiceDb & { rows: Map<string, Row> } = {
|
|
55
|
+
rows,
|
|
56
|
+
workspace: {
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
findMany: async (args?: any) => {
|
|
59
|
+
const out = [...rows.values()].filter((r) => match(r, args?.where));
|
|
60
|
+
if (args?.orderBy?.path === "asc")
|
|
61
|
+
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
62
|
+
return out;
|
|
63
|
+
},
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
findUnique: async (args: any) => rows.get(args.where.id) ?? null,
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
create: async (args: any) => {
|
|
68
|
+
const row = { isActive: true, ...args.data } as Row;
|
|
69
|
+
rows.set(row.id, row);
|
|
70
|
+
return row;
|
|
71
|
+
},
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
+
update: async (args: any) => {
|
|
74
|
+
const row = rows.get(args.where.id);
|
|
75
|
+
if (!row) throw new Error("không có dòng để update");
|
|
76
|
+
Object.assign(row, args.data);
|
|
77
|
+
return row;
|
|
78
|
+
},
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
count: async (args?: any) =>
|
|
81
|
+
[...rows.values()].filter((r) => match(r, args?.where)).length,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return db;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Cây mẫu giống bộ test phạm vi: 2 đơn vị, Spartronics có 2 phòng ban. */
|
|
88
|
+
function seedTree(): Row[] {
|
|
89
|
+
return [
|
|
90
|
+
row("tanloc", null, "/tanloc/", 1, "unit"),
|
|
91
|
+
row("spa", null, "/spa/", 1, "unit"),
|
|
92
|
+
row("spa-qc", "spa", "/spa/spa-qc/", 2, "department"),
|
|
93
|
+
row("spa-kho", "spa", "/spa/spa-kho/", 2, "department"),
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function row(
|
|
98
|
+
id: string,
|
|
99
|
+
parentId: string | null,
|
|
100
|
+
path: string,
|
|
101
|
+
depth: number,
|
|
102
|
+
kind: string,
|
|
103
|
+
): Row {
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
code: id.toUpperCase(),
|
|
107
|
+
name: id,
|
|
108
|
+
kind,
|
|
109
|
+
parentId,
|
|
110
|
+
path,
|
|
111
|
+
depth,
|
|
112
|
+
isActive: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
resetWorkspaceConfig();
|
|
118
|
+
configureWorkspaces({
|
|
119
|
+
getUserId: () => undefined,
|
|
120
|
+
canViewAll: () => false,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("createWorkspace", () => {
|
|
125
|
+
it("nút gốc: path là /{id}/ và depth 1", async () => {
|
|
126
|
+
const db = fakeDb();
|
|
127
|
+
const created = await createWorkspace(db, { code: "TL", name: "Tấn Lộc" });
|
|
128
|
+
expect(created.path).toBe(`/${created.id}/`);
|
|
129
|
+
expect(created.depth).toBe(1);
|
|
130
|
+
expect(created.parentId).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("nút con nối path của cha và id nằm TRONG path", async () => {
|
|
134
|
+
const db = fakeDb(seedTree());
|
|
135
|
+
const created = await createWorkspace(db, {
|
|
136
|
+
code: "SPA-IT",
|
|
137
|
+
name: "IT",
|
|
138
|
+
kind: "department",
|
|
139
|
+
parentId: "spa",
|
|
140
|
+
});
|
|
141
|
+
expect(created.path).toBe(`/spa/${created.id}/`);
|
|
142
|
+
expect(created.depth).toBe(2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("id truyền vào được giữ nguyên — đường backfill workspace.id = branch.id", async () => {
|
|
146
|
+
const db = fakeDb();
|
|
147
|
+
const created = await createWorkspace(db, {
|
|
148
|
+
id: "cn-hcm",
|
|
149
|
+
code: "HCM",
|
|
150
|
+
name: "Hồ Chí Minh",
|
|
151
|
+
kind: "branch",
|
|
152
|
+
});
|
|
153
|
+
expect(created.id).toBe("cn-hcm");
|
|
154
|
+
expect(created.path).toBe("/cn-hcm/");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("cha không tồn tại thì ném, không tạo nút mồ côi", async () => {
|
|
158
|
+
const db = fakeDb(seedTree());
|
|
159
|
+
await expect(
|
|
160
|
+
createWorkspace(db, { code: "X", name: "X", parentId: "khong-co" }),
|
|
161
|
+
).rejects.toThrow(WorkspaceTreeError);
|
|
162
|
+
expect(db.rows.size).toBe(4);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("vượt trần độ sâu thì ném TRƯỚC khi ghi", async () => {
|
|
166
|
+
resetWorkspaceConfig();
|
|
167
|
+
configureWorkspaces({
|
|
168
|
+
getUserId: () => undefined,
|
|
169
|
+
canViewAll: () => false,
|
|
170
|
+
maxDepth: 2,
|
|
171
|
+
});
|
|
172
|
+
const db = fakeDb(seedTree());
|
|
173
|
+
await expect(
|
|
174
|
+
createWorkspace(db, {
|
|
175
|
+
code: "X",
|
|
176
|
+
name: "X",
|
|
177
|
+
kind: "department",
|
|
178
|
+
parentId: "spa-qc",
|
|
179
|
+
}),
|
|
180
|
+
).rejects.toThrow(WorkspaceTreeError);
|
|
181
|
+
expect(db.rows.size).toBe(4);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("kind khai canHaveChildren: false thì chặn tạo con", async () => {
|
|
185
|
+
resetWorkspaceConfig();
|
|
186
|
+
configureWorkspaces({
|
|
187
|
+
getUserId: () => undefined,
|
|
188
|
+
canViewAll: () => false,
|
|
189
|
+
kinds: [
|
|
190
|
+
{ key: "unit", label: "Đơn vị", childKinds: ["department"] },
|
|
191
|
+
{ key: "department", label: "Bộ phận", canHaveChildren: false },
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
const db = fakeDb(seedTree());
|
|
195
|
+
await expect(
|
|
196
|
+
createWorkspace(db, {
|
|
197
|
+
code: "X",
|
|
198
|
+
name: "X",
|
|
199
|
+
kind: "department",
|
|
200
|
+
parentId: "spa-qc",
|
|
201
|
+
}),
|
|
202
|
+
).rejects.toThrow(/không được có workspace con/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("kind ngoài childKinds của cha thì chặn", async () => {
|
|
206
|
+
resetWorkspaceConfig();
|
|
207
|
+
configureWorkspaces({
|
|
208
|
+
getUserId: () => undefined,
|
|
209
|
+
canViewAll: () => false,
|
|
210
|
+
kinds: [
|
|
211
|
+
{ key: "unit", label: "Đơn vị", childKinds: ["department"] },
|
|
212
|
+
{ key: "department", label: "Bộ phận" },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
const db = fakeDb(seedTree());
|
|
216
|
+
await expect(
|
|
217
|
+
createWorkspace(db, {
|
|
218
|
+
code: "X",
|
|
219
|
+
name: "X",
|
|
220
|
+
kind: "unit",
|
|
221
|
+
parentId: "spa",
|
|
222
|
+
}),
|
|
223
|
+
).rejects.toThrow(/chỉ đặt được/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("quá nhiều con thì CẢNH BÁO chứ không chặn", async () => {
|
|
227
|
+
resetWorkspaceConfig();
|
|
228
|
+
configureWorkspaces({
|
|
229
|
+
getUserId: () => undefined,
|
|
230
|
+
canViewAll: () => false,
|
|
231
|
+
maxChildren: 2,
|
|
232
|
+
});
|
|
233
|
+
const db = fakeDb(seedTree());
|
|
234
|
+
const onWarn = vi.fn();
|
|
235
|
+
const created = await createWorkspace(
|
|
236
|
+
db,
|
|
237
|
+
{ code: "X", name: "X", kind: "department", parentId: "spa" },
|
|
238
|
+
{ onWarn },
|
|
239
|
+
);
|
|
240
|
+
expect(created.id).toBeTruthy();
|
|
241
|
+
expect(onWarn).toHaveBeenCalledOnce();
|
|
242
|
+
expect(onWarn.mock.calls[0][0]).toMatch(/2 nút con/);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("moveWorkspace", () => {
|
|
247
|
+
it("chuyển nhánh thì repath CẢ con cháu", async () => {
|
|
248
|
+
const db = fakeDb(seedTree());
|
|
249
|
+
const count = await moveWorkspace(db, { id: "spa", newParentId: "tanloc" });
|
|
250
|
+
expect(count).toBe(3);
|
|
251
|
+
expect(db.rows.get("spa")!.path).toBe("/tanloc/spa/");
|
|
252
|
+
expect(db.rows.get("spa-qc")!.path).toBe("/tanloc/spa/spa-qc/");
|
|
253
|
+
expect(db.rows.get("spa-kho")!.depth).toBe(3);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("chỉ nút được chuyển đổi parentId; con cháu giữ nguyên cha của chúng", async () => {
|
|
257
|
+
const db = fakeDb(seedTree());
|
|
258
|
+
await moveWorkspace(db, { id: "spa", newParentId: "tanloc" });
|
|
259
|
+
expect(db.rows.get("spa")!.parentId).toBe("tanloc");
|
|
260
|
+
expect(db.rows.get("spa-qc")!.parentId).toBe("spa");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("chuyển lên gốc", async () => {
|
|
264
|
+
const db = fakeDb(seedTree());
|
|
265
|
+
await moveWorkspace(db, { id: "spa-qc", newParentId: null });
|
|
266
|
+
expect(db.rows.get("spa-qc")!.path).toBe("/spa-qc/");
|
|
267
|
+
expect(db.rows.get("spa-qc")!.parentId).toBeNull();
|
|
268
|
+
expect(db.rows.get("spa-qc")!.depth).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("chuyển vào chính con cháu mình = chu trình → ném, không ghi gì", async () => {
|
|
272
|
+
const db = fakeDb(seedTree());
|
|
273
|
+
await expect(
|
|
274
|
+
moveWorkspace(db, { id: "spa", newParentId: "spa-qc" }),
|
|
275
|
+
).rejects.toThrow(WorkspaceTreeError);
|
|
276
|
+
expect(db.rows.get("spa")!.path).toBe("/spa/");
|
|
277
|
+
expect(db.rows.get("spa-qc")!.path).toBe("/spa/spa-qc/");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("nhánh sâu chuyển xuống làm vượt trần → ném TRƯỚC khi ghi dòng nào", async () => {
|
|
281
|
+
resetWorkspaceConfig();
|
|
282
|
+
configureWorkspaces({
|
|
283
|
+
getUserId: () => undefined,
|
|
284
|
+
canViewAll: () => false,
|
|
285
|
+
maxDepth: 2,
|
|
286
|
+
});
|
|
287
|
+
const db = fakeDb(seedTree());
|
|
288
|
+
// /spa/ (1) + con (2) chuyển xuống /tanloc/ ⇒ con thành depth 3 > trần 2.
|
|
289
|
+
await expect(
|
|
290
|
+
moveWorkspace(db, { id: "spa", newParentId: "tanloc" }),
|
|
291
|
+
).rejects.toThrow(WorkspaceTreeError);
|
|
292
|
+
expect(db.rows.get("spa")!.path).toBe("/spa/");
|
|
293
|
+
expect(db.rows.get("spa-qc")!.path).toBe("/spa/spa-qc/");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("listSubtree / deactivateSubtree", () => {
|
|
298
|
+
it("listSubtree gồm cả chính nó, sắp theo path", async () => {
|
|
299
|
+
const db = fakeDb(seedTree());
|
|
300
|
+
const rows = await listSubtree(db, "spa");
|
|
301
|
+
expect(rows.map((r) => r.id)).toEqual(["spa", "spa-kho", "spa-qc"]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("nút không tồn tại → mảng rỗng", async () => {
|
|
305
|
+
const db = fakeDb(seedTree());
|
|
306
|
+
expect(await listSubtree(db, "khong-co")).toEqual([]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("deactivateSubtree tắt cả nhánh, KHÔNG xoá dòng nào", async () => {
|
|
310
|
+
const db = fakeDb(seedTree());
|
|
311
|
+
const count = await deactivateSubtree(db, "spa");
|
|
312
|
+
expect(count).toBe(3);
|
|
313
|
+
expect(db.rows.size).toBe(4);
|
|
314
|
+
expect(db.rows.get("spa-qc")!.isActive).toBe(false);
|
|
315
|
+
expect(db.rows.get("tanloc")!.isActive).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("buildTree", () => {
|
|
320
|
+
it("dựng cây lồng nhau từ danh sách phẳng", () => {
|
|
321
|
+
const nodes: WorkspaceNode[] = seedTree().map((r) => ({
|
|
322
|
+
id: r.id,
|
|
323
|
+
parentId: r.parentId,
|
|
324
|
+
path: r.path,
|
|
325
|
+
kind: r.kind,
|
|
326
|
+
}));
|
|
327
|
+
const roots = buildTree(nodes);
|
|
328
|
+
expect(roots.map((r) => r.id)).toEqual(["tanloc", "spa"]);
|
|
329
|
+
expect(roots[1].children.map((c) => c.id)).toEqual(["spa-qc", "spa-kho"]);
|
|
330
|
+
expect(roots[0].children).toEqual([]);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("nút có cha KHÔNG nằm trong danh sách vẫn hiện ra như gốc, không biến mất", () => {
|
|
334
|
+
const roots = buildTree([
|
|
335
|
+
{ id: "spa-qc", parentId: "spa", path: "/spa/spa-qc/", kind: "dept" },
|
|
336
|
+
]);
|
|
337
|
+
expect(roots.map((r) => r.id)).toEqual(["spa-qc"]);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Thang phạm vi 5 nấc (Δ1 — theo Dynamics 365). Dùng ở trang Vai trò: mỗi quyền
|
|
4
|
+
// không chỉ có/không, mà còn "rộng tới đâu".
|
|
5
|
+
//
|
|
6
|
+
// Lý do là một component riêng chứ không phải `<Select>` gõ tay tại chỗ: chữ
|
|
7
|
+
// hiển thị của mỗi nấc phụ thuộc NHÃN của app ("Chi nhánh của tôi" ở vinhhoa,
|
|
8
|
+
// "Đơn vị của tôi" ở spartronics). Rải ra call-site thì mỗi trang một chữ.
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
} from "../../ui";
|
|
18
|
+
import { SCOPE_LEVELS, SCOPE_LEVEL_LABELS } from "../types";
|
|
19
|
+
import type { ScopeLevel } from "../types";
|
|
20
|
+
|
|
21
|
+
/** Mô tả ngắn dưới mỗi nấc — người tick quyền cần biết "rộng tới đâu". */
|
|
22
|
+
const HINTS: Record<ScopeLevel, string> = {
|
|
23
|
+
none: "Không thấy gì",
|
|
24
|
+
own: "Chỉ bản ghi do chính mình tạo",
|
|
25
|
+
workspace: "Đúng workspace được gán",
|
|
26
|
+
subtree: "Workspace được gán và cấp dưới",
|
|
27
|
+
all: "Toàn hệ thống",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface ScopeLevelSelectProps {
|
|
31
|
+
value: ScopeLevel;
|
|
32
|
+
onChange: (level: ScopeLevel) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Nấc CAO NHẤT được chọn. Admin nhánh không được cấp đi nấc `all` — cắt ngay ở
|
|
35
|
+
* dropdown thay vì để họ chọn rồi server mới từ chối.
|
|
36
|
+
*/
|
|
37
|
+
maxLevel?: ScopeLevel;
|
|
38
|
+
/** Nhãn thay cho "Workspace" theo app: "Chi nhánh", "Đơn vị", "Phòng ban". */
|
|
39
|
+
kindLabel?: string;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
className?: string;
|
|
42
|
+
id?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Nhãn nấc, đã thay chữ "Workspace" bằng nhãn của app. */
|
|
46
|
+
export function scopeLevelLabel(level: ScopeLevel, kindLabel?: string): string {
|
|
47
|
+
const base = SCOPE_LEVEL_LABELS[level];
|
|
48
|
+
if (!kindLabel) return base;
|
|
49
|
+
if (level === "workspace") return `${kindLabel} của tôi`;
|
|
50
|
+
if (level === "subtree") return `${kindLabel} và cấp dưới`;
|
|
51
|
+
return base;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ScopeLevelSelect({
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
maxLevel = "all",
|
|
58
|
+
kindLabel,
|
|
59
|
+
disabled,
|
|
60
|
+
className,
|
|
61
|
+
id,
|
|
62
|
+
}: ScopeLevelSelectProps) {
|
|
63
|
+
const options = React.useMemo(() => {
|
|
64
|
+
const cap = SCOPE_LEVELS.indexOf(maxLevel);
|
|
65
|
+
return SCOPE_LEVELS.slice(0, cap >= 0 ? cap + 1 : SCOPE_LEVELS.length);
|
|
66
|
+
}, [maxLevel]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Select
|
|
70
|
+
value={value}
|
|
71
|
+
onValueChange={(v) => onChange(v as ScopeLevel)}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
>
|
|
74
|
+
<SelectTrigger id={id} className={className}>
|
|
75
|
+
<SelectValue placeholder="Chọn phạm vi" />
|
|
76
|
+
</SelectTrigger>
|
|
77
|
+
<SelectContent>
|
|
78
|
+
{options.map((level) => (
|
|
79
|
+
<SelectItem key={level} value={level}>
|
|
80
|
+
<span className="flex flex-col items-start">
|
|
81
|
+
<span>{scopeLevelLabel(level, kindLabel)}</span>
|
|
82
|
+
<span className="text-xs text-muted-foreground">
|
|
83
|
+
{HINTS[level]}
|
|
84
|
+
</span>
|
|
85
|
+
</span>
|
|
86
|
+
</SelectItem>
|
|
87
|
+
))}
|
|
88
|
+
</SelectContent>
|
|
89
|
+
</Select>
|
|
90
|
+
);
|
|
91
|
+
}
|