@allbluecn/web-app 0.5.1 → 0.6.1

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.
Files changed (125) hide show
  1. package/package.json +230 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  12. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  13. package/src/components/project/create-project-dialog.tsx +44 -57
  14. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  15. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  16. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  17. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  18. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  19. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  20. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  21. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  22. package/src/components/story/__tests__/constants.test.ts +255 -0
  23. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  24. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  25. package/src/components/story/constants.ts +33 -1
  26. package/src/components/story/create-story-dialog.tsx +30 -10
  27. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  28. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  29. package/src/components/story/detail/activity-timeline.tsx +11 -4
  30. package/src/components/story/detail/story-detail-body.tsx +89 -38
  31. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
  32. package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
  33. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
  34. package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
  35. package/src/components/story/detail/story-properties.tsx +21 -4
  36. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  37. package/src/components/story/display/group-resolver.tsx +2 -2
  38. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  39. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  40. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  41. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  42. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  43. package/src/components/story/inline-editors/index.ts +2 -1
  44. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  45. package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
  46. package/src/components/story/peek-panel.tsx +121 -21
  47. package/src/components/story/relations/link-manage-dialog.tsx +2 -1
  48. package/src/components/story/relations/story-ref-row.tsx +17 -27
  49. package/src/components/story/story-panel-header.tsx +25 -19
  50. package/src/components/story/story-toolbar.tsx +3 -3
  51. package/src/components/story/transfer-story-popover.tsx +2 -2
  52. package/src/components/story/types.ts +1 -0
  53. package/src/components/story/views/create-view-dialog.tsx +3 -3
  54. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  55. package/src/components/story/views/story-list/group.tsx +1 -1
  56. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  57. package/src/components/story/views/story-list/row.tsx +1 -1
  58. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  59. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  60. package/src/lib/changelog/sdk-versions.ts +20 -20
  61. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  62. package/src/lib/iteration/status.ts +28 -0
  63. package/src/lib/project-display-id.ts +14 -5
  64. package/src/lib/project-module-meta.ts +77 -0
  65. package/src/lib/utils.d.ts +1 -1
  66. package/src/providers/index.tsx +6 -0
  67. package/src/routeTree.gen.ts +54 -0
  68. package/src/routes/__root.tsx +0 -8
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  70. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  71. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  72. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  73. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  74. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  75. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  76. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  77. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  78. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
  79. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  80. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  81. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  82. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  83. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  84. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  85. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  86. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  87. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  88. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  89. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  90. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  91. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  92. package/src/server/serverFns/iteration/index.ts +22 -0
  93. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  94. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  95. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  96. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  97. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  98. package/src/server/serverFns/iteration/schemas.ts +58 -0
  99. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  100. package/src/server/serverFns/project/module-overview.ts +109 -0
  101. package/src/server/serverFns/project.ts +2 -2
  102. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  103. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  104. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
  105. package/src/server/serverFns/story/index.ts +3 -0
  106. package/src/server/serverFns/story/story-assoc.ts +36 -4
  107. package/src/server/serverFns/story/story-attachments.ts +20 -2
  108. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  109. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  110. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  111. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  112. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  113. package/src/server/serverFns/story/story-crud.ts +32 -2
  114. package/src/server/serverFns/story/story-dependencies.ts +123 -2
  115. package/src/server/serverFns/story/story-links.ts +30 -6
  116. package/src/server/serverFns/story/story-transfer.ts +5 -4
  117. package/src/server/serverFns/story/types.ts +1 -0
  118. package/src/types/project.ts +5 -5
  119. package/src/components/project/project-animated-icon.tsx +0 -73
  120. package/src/components/shared/icon-packs/index.ts +0 -1
  121. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  122. package/src/components/shared/icon-packs/prd.ts +0 -1
  123. package/src/components/shared/icon-packs/project.ts +0 -1
  124. package/src/components/shared/icon-picker.tsx +0 -1
  125. package/src/lib/project-mock.ts +0 -156
@@ -29,17 +29,14 @@ import {
29
29
  } from "@/components/ui/select";
30
30
  import { Button } from "@/components/ui/button";
31
31
  import { Input } from "@/components/ui/input";
32
- import { Textarea } from "@/components/ui/textarea";
33
32
  import {
34
33
  Dialog,
35
34
  DialogContent,
36
- DialogDescription,
37
35
  DialogFooter,
38
36
  DialogHeader,
39
37
  DialogTitle,
40
38
  } from "@/components/ui/dialog";
41
- import { IconPicker } from "@/components/shared/icon-picker";
42
- import { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
39
+ import { LucideIconPicker } from "@/components/shared/lucide-icon-picker";
43
40
  import { extractDisplayId, validateDisplayId } from "@/lib/project-display-id";
44
41
  import { checkDisplayIdAvailableFn, createProjectFn } from "@/server/serverFns/project";
45
42
  import { listWorkspacesBriefFn } from "@/server/serverFns/workspace";
@@ -77,7 +74,7 @@ export function CreateProjectDialog({
77
74
  defaultValues: {
78
75
  name: "",
79
76
  description: "",
80
- icon: "folder-kanban",
77
+ icon: "",
81
78
  displayId: "",
82
79
  workspaceId: defaultWorkspaceId ?? "",
83
80
  },
@@ -134,7 +131,7 @@ export function CreateProjectDialog({
134
131
 
135
132
  const handleOpenChange = (newOpen: boolean) => {
136
133
  if (newOpen) {
137
- form.setFieldValue("icon", "folder-kanban");
134
+ form.setFieldValue("icon", "");
138
135
  form.setFieldValue("name", "");
139
136
  form.setFieldValue("description", "");
140
137
  form.setFieldValue("displayId", "");
@@ -148,25 +145,20 @@ export function CreateProjectDialog({
148
145
 
149
146
  return (
150
147
  <Dialog open={open} onOpenChange={handleOpenChange}>
151
- <DialogContent className="sm:max-w-200">
152
- <DialogHeader className="p-4">
153
- <DialogTitle>{t("project.create.title")}</DialogTitle>
154
- <DialogDescription>{t("project.create.description")}</DialogDescription>
148
+ <DialogContent className="sm:max-w-260 -mt-50">
149
+ <DialogHeader>
150
+ <DialogTitle className="text-xl">{t("project.create.title")}</DialogTitle>
155
151
  </DialogHeader>
156
152
 
157
- <div className="space-y-4 p-4">
158
- <div className="flex flex-col gap-3 mb-20">
153
+ <div className="space-y-3 overflow-y-auto px-4 py-2 max-h-[60vh]">
154
+ <div className="flex gap-2">
159
155
  <form.Field name="icon" children={(field) => (
160
- <IconPicker
161
- icons={PROJECT_ICONS}
162
- value={field.value ?? "folder-kanban"}
156
+ <LucideIconPicker
157
+ value={field.value}
163
158
  onChange={(v) => field.handleChange(v)}
159
+ modal
164
160
  />
165
161
  )} />
166
- </div>
167
-
168
- <div className="flex flex-col gap-3">
169
- <label className="text-sm font-medium ml-2">{t("project.create.nameLabel")} *</label>
170
162
  <form.Field name="name" children={(field) => (
171
163
  <Input
172
164
  value={field.value ?? ""}
@@ -174,36 +166,11 @@ export function CreateProjectDialog({
174
166
  onBlur={field.handleBlur}
175
167
  placeholder={t("project.create.namePlaceholder")}
176
168
  maxLength={100}
169
+ className="h-10 min-w-0 flex-1"
177
170
  />
178
171
  )} />
179
- </div>
180
-
181
- <div className="flex flex-col gap-3">
182
- <label className="text-sm font-medium ml-2">{t("project.create.idLabel")} *</label>
183
- <form.Field name="displayId" children={(field) => (
184
- <Input
185
- value={field.value ?? ""}
186
- onChange={(e) => handleDisplayIdChange(e.target.value)}
187
- onBlur={async () => {
188
- field.handleBlur();
189
- if (!field.value || validateDisplayId(field.value)) return;
190
- const res = await checkDisplayIdAvailableFn({ data: { displayId: field.value } });
191
- setDisplayIdError(res.available ? "" : res.reason ?? t("project.create.idUnavailable"));
192
- }}
193
- placeholder={t("project.create.idPlaceholder")}
194
- maxLength={10}
195
- className={displayIdError ? "border-destructive" : ""}
196
- />
197
- )} />
198
- {displayIdError && (
199
- <span className="ml-2 text-xs text-destructive">{displayIdError}</span>
200
- )}
201
- </div>
202
-
203
- <div className="flex flex-col gap-3">
204
- <label className="text-sm font-medium ml-2">{t("project.create.workspaceLabel")} *</label>
205
172
  {defaultWorkspaceId ? (
206
- <span className="ml-2 px-3 py-2 rounded-md bg-muted border text-sm">
173
+ <span className="flex h-10 shrink-0 max-w-60 items-center truncate rounded-md border bg-muted px-3 text-sm">
207
174
  {defaultWorkspaceName ?? t("project.create.workspaceSelected")}
208
175
  </span>
209
176
  ) : (
@@ -212,7 +179,7 @@ if (!field.value || validateDisplayId(field.value)) return;
212
179
  value={field.value ?? ""}
213
180
  onValueChange={(v) => field.handleChange(v)}
214
181
  >
215
- <SelectTrigger className="ml-2">
182
+ <SelectTrigger className="h-10 w-44 shrink-0">
216
183
  <SelectValue placeholder={t("project.create.workspacePlaceholder")} />
217
184
  </SelectTrigger>
218
185
  <SelectContent>
@@ -227,29 +194,49 @@ if (!field.value || validateDisplayId(field.value)) return;
227
194
  )}
228
195
  </div>
229
196
 
230
- <div className="space-y-2">
231
- <label className="text-sm font-medium ml-2">{t("project.create.introLabel")}</label>
232
- <form.Field name="description" children={(field) => (
233
- <Textarea
197
+ <form.Field name="displayId" children={(field) => (
198
+ <div>
199
+ <Input
200
+ value={field.value ?? ""}
201
+ onChange={(e) => handleDisplayIdChange(e.target.value)}
202
+ onBlur={async () => {
203
+ field.handleBlur();
204
+ if (!field.value || validateDisplayId(field.value)) return;
205
+ const res = await checkDisplayIdAvailableFn({ data: { displayId: field.value } });
206
+ setDisplayIdError(res.available ? "" : res.reason ?? t("project.create.idUnavailable"));
207
+ }}
208
+ placeholder={t("project.create.idPlaceholder")}
209
+ maxLength={10}
210
+ className={`h-10 font-mono ${displayIdError ? "border-destructive" : ""}`}
211
+ />
212
+ {displayIdError && (
213
+ <p className="mt-1.5 text-xs text-destructive">{displayIdError}</p>
214
+ )}
215
+ </div>
216
+ )} />
217
+
218
+ <form.Field name="description" children={(field) => (
219
+ <div className="notion-full-width min-h-50 max-h-150 flex flex-col overflow-y-auto border rounded-md border-border">
220
+ <textarea
234
221
  value={field.value ?? ""}
235
222
  onChange={(e) => field.handleChange(e.target.value)}
236
223
  onBlur={field.handleBlur}
237
224
  placeholder={t("project.create.introPlaceholder")}
238
225
  maxLength={500}
239
- rows={3}
226
+ className="flex-1 min-h-0 resize-none bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground"
240
227
  />
241
- )} />
242
- </div>
228
+ </div>
229
+ )} />
243
230
 
244
231
  {error && <p className="text-sm text-destructive break-words">{error}</p>}
245
232
  </div>
246
233
 
247
- <DialogFooter>
234
+ <DialogFooter className="items-center gap-3">
248
235
  <Button variant="outline" onClick={() => handleOpenChange(false)}>
249
- {t("button.cancel")}
236
+ {t("common:button.cancel")}
250
237
  </Button>
251
238
  <Button onClick={handleSubmit} disabled={loading}>
252
- {loading ? t("project.create.creating") : t("button.create")}
239
+ {loading ? t("project.create.creating") : t("common:button.create")}
253
240
  </Button>
254
241
  </DialogFooter>
255
242
  </DialogContent>
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, expect, it } from "vitest";
19
+ import { icons } from "lucide-react";
20
+ import { CURATED_ICONS } from "@/components/shared/lucide-icon-picker/curated-icons";
21
+ import { pascalToKebab } from "@/components/shared/lucide-icon-picker/icon-data";
22
+
23
+ const BANNED_SUFFIX = /-(off|slash|ban|x)$/;
24
+ const BANNED_NUMBER_TAIL = /-[0-9]+$/;
25
+ /** lucide 官方正名含数字且常用的豁免清单(历史改名产物,非编号家族) */
26
+ const NUMBER_TAIL_ALLOWLIST = new Set([
27
+ "undo-2",
28
+ "redo-2",
29
+ "share-2",
30
+ "volume-1",
31
+ "volume-2",
32
+ "gamepad-2",
33
+ "rows-3",
34
+ "columns-3",
35
+ ]);
36
+
37
+ describe("CURATED_ICONS 精选名单完整性", () => {
38
+ it("数量在目标区间 400-520", () => {
39
+ expect(CURATED_ICONS.length).toBeGreaterThanOrEqual(400);
40
+ expect(CURATED_ICONS.length).toBeLessThanOrEqual(520);
41
+ });
42
+
43
+ it("name 无重复", () => {
44
+ const names = CURATED_ICONS.map((i) => i.name);
45
+ expect(new Set(names).size).toBe(names.length);
46
+ });
47
+
48
+ it("不含否定/关闭变体后缀", () => {
49
+ const bad = CURATED_ICONS.filter((i) => BANNED_SUFFIX.test(i.name));
50
+ expect(bad.map((i) => i.name)).toEqual([]);
51
+ });
52
+
53
+ it("不含数字结尾编号变体(豁免清单除外)", () => {
54
+ const bad = CURATED_ICONS.filter(
55
+ (i) => BANNED_NUMBER_TAIL.test(i.name) && !NUMBER_TAIL_ALLOWLIST.has(i.name)
56
+ );
57
+ expect(bad.map((i) => i.name)).toEqual([]);
58
+ });
59
+
60
+ it("name 与 lucide 官方组件名一一对应(pascalToKebab 校验)", () => {
61
+ const kebabSet = new Set(Object.keys(icons).map(pascalToKebab));
62
+ const bad = CURATED_ICONS.filter((i) => !kebabSet.has(i.name));
63
+ expect(bad.map((i) => i.name)).toEqual([]);
64
+ });
65
+
66
+ it("Icon 引用与 name 相互一致(反查 icons map 键再转 kebab 相等)", () => {
67
+ const entryByPascal = new Map(
68
+ Object.entries(icons).map(([pascal, Comp]) => [Comp, pascal])
69
+ );
70
+ const bad = CURATED_ICONS.filter((i) => {
71
+ const pascal = entryByPascal.get(i.Icon);
72
+ return !pascal || pascalToKebab(pascal) !== i.name;
73
+ });
74
+ expect(bad.map((i) => i.name)).toEqual([]);
75
+ });
76
+
77
+ it("每项 zh 关键词非空且无空字符串", () => {
78
+ const bad = CURATED_ICONS.filter(
79
+ (i) => i.zh.length === 0 || i.zh.some((k) => !k.trim())
80
+ );
81
+ expect(bad.map((i) => i.name)).toEqual([]);
82
+ });
83
+ });
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { afterEach, describe, expect, it, vi } from "vitest";
19
+ import {
20
+ buildSearchIndex,
21
+ filterIcons,
22
+ getRecentIcons,
23
+ isCJK,
24
+ pascalToKebab,
25
+ pushRecentIcon,
26
+ type CuratedIcon,
27
+ } from "@/components/shared/lucide-icon-picker/icon-data";
28
+ import { Star, Clock, FolderKanban } from "lucide-react";
29
+
30
+ const fakeIcons: CuratedIcon[] = [
31
+ { name: "star", Icon: Star, zh: ["星标", "收藏", "评分"] },
32
+ { name: "clock", Icon: Clock, zh: ["时钟", "时间"] },
33
+ { name: "folder-kanban", Icon: FolderKanban, zh: ["看板", "项目"] },
34
+ ];
35
+
36
+ describe("pascalToKebab", () => {
37
+ it("常规 PascalCase 转换", () => {
38
+ expect(pascalToKebab("FolderKanban")).toBe("folder-kanban");
39
+ expect(pascalToKebab("Star")).toBe("star");
40
+ });
41
+ it("连续大写开头(AArrowDown)", () => {
42
+ expect(pascalToKebab("AArrowDown")).toBe("a-arrow-down");
43
+ });
44
+ it("字母后接数字(Grid2x2 → grid-2x-2)", () => {
45
+ expect(pascalToKebab("Grid2x2")).toBe("grid-2x-2");
46
+ });
47
+ it("全小写单词(Github)", () => {
48
+ expect(pascalToKebab("Github")).toBe("github");
49
+ });
50
+ });
51
+
52
+ describe("isCJK", () => {
53
+ it("中文判真", () => {
54
+ expect(isCJK("星标")).toBe(true);
55
+ expect(isCJK("收藏")).toBe(true);
56
+ });
57
+ it("英文判假", () => {
58
+ expect(isCJK("star")).toBe(false);
59
+ expect(isCJK("")).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("buildSearchIndex + filterIcons", () => {
64
+ const index = buildSearchIndex(fakeIcons);
65
+
66
+ it("英文子串匹配", () => {
67
+ const names = filterIcons("star", index).map((e) => e.name);
68
+ expect(names).toEqual(["star"]);
69
+ });
70
+ it("kebab 分词匹配(folder-kanban 可用 kanban 命中)", () => {
71
+ const names = filterIcons("kanban", index).map((e) => e.name);
72
+ expect(names).toEqual(["folder-kanban"]);
73
+ });
74
+ it("大小写不敏感", () => {
75
+ const names = filterIcons("STAR", index).map((e) => e.name);
76
+ expect(names).toEqual(["star"]);
77
+ });
78
+ it("中文关键词匹配", () => {
79
+ const names = filterIcons("时间", index).map((e) => e.name);
80
+ expect(names).toEqual(["clock"]);
81
+ });
82
+ it("中文部分子串匹配", () => {
83
+ const names = filterIcons("星", index).map((e) => e.name);
84
+ expect(names).toEqual(["star"]);
85
+ });
86
+ it("无结果返回空数组", () => {
87
+ expect(filterIcons("zzz不存在", index)).toEqual([]);
88
+ });
89
+ it("结果保留 Icon 引用供渲染", () => {
90
+ const results = filterIcons("star", index);
91
+ expect(results[0].Icon).toBe(Star);
92
+ });
93
+ });
94
+
95
+ describe("recent icons (localStorage)", () => {
96
+ afterEach(() => {
97
+ // 顺序关键:先恢复被 stub 的 window,再操作 localStorage——
98
+ // SSR 用例 stubGlobal("window", undefined) 后若先 clear 会抛 TypeError
99
+ vi.unstubAllGlobals();
100
+ window.localStorage.clear();
101
+ });
102
+
103
+ it("空存储返回空数组", () => {
104
+ expect(getRecentIcons()).toEqual([]);
105
+ });
106
+ it("写入后最新在前", () => {
107
+ pushRecentIcon("star");
108
+ pushRecentIcon("clock");
109
+ expect(getRecentIcons()).toEqual(["clock", "star"]);
110
+ });
111
+ it("重复写入去重并前移", () => {
112
+ pushRecentIcon("star");
113
+ pushRecentIcon("clock");
114
+ pushRecentIcon("star");
115
+ expect(getRecentIcons()).toEqual(["star", "clock"]);
116
+ });
117
+ it("上限 16 截断", () => {
118
+ for (let i = 0; i < 20; i++) pushRecentIcon(`icon-${i}`);
119
+ expect(getRecentIcons()).toHaveLength(16);
120
+ expect(getRecentIcons()[0]).toBe("icon-19");
121
+ });
122
+ it("损坏 JSON 返回空数组不抛错", () => {
123
+ window.localStorage.setItem("allblue.ui.lucide-icon-picker.recent", "{broken");
124
+ expect(getRecentIcons()).toEqual([]);
125
+ });
126
+ it("SSR 无 window 安全", () => {
127
+ vi.stubGlobal("window", undefined);
128
+ expect(getRecentIcons()).toEqual([]);
129
+ expect(pushRecentIcon("star")).toEqual(["star"]);
130
+ });
131
+ });
@@ -0,0 +1,197 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { beforeEach, describe, expect, it, vi } from "vitest";
19
+ import { render, screen, waitFor } from "@testing-library/react";
20
+ import userEvent from "@testing-library/user-event";
21
+ import { Star } from "lucide-react";
22
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
23
+ import { IconPickerPanel } from "@/components/shared/lucide-icon-picker/panel";
24
+ import { LucideIconPicker } from "@/components/shared/lucide-icon-picker";
25
+ import { CURATED_ICONS } from "@/components/shared/lucide-icon-picker/curated-icons";
26
+ import { pushRecentIcon, getRecentIcons } from "@/components/shared/lucide-icon-picker/icon-data";
27
+
28
+ describe("LucideIconByName", () => {
29
+ it("有效 name 渲染对应图标(jsdom 下 data-icon 属性不可靠,以 width 断言)", () => {
30
+ const { container } = render(<LucideIconByName name="star" size={20} />);
31
+ const svg = container.querySelector("svg");
32
+ expect(svg).toBeTruthy();
33
+ expect(svg?.getAttribute("width")).toBe("20");
34
+ expect(svg?.innerHTML).toContain("<path");
35
+ });
36
+
37
+ it("精选外/未知 name 渲染 fallback(默认 Circle 占位)", () => {
38
+ const { container } = render(<LucideIconByName name="not-exist-icon" />);
39
+ const svg = container.querySelector("svg");
40
+ expect(svg).toBeTruthy();
41
+ expect(svg?.innerHTML).toContain("<circle");
42
+ });
43
+
44
+ it("undefined name 渲染 fallback", () => {
45
+ const { container } = render(<LucideIconByName />);
46
+ expect(container.querySelector("svg")).toBeTruthy();
47
+ });
48
+
49
+ it("自定义 fallback 优先于默认占位", () => {
50
+ render(
51
+ <LucideIconByName name="not-exist" fallback={<Star data-testid="fb" />} />
52
+ );
53
+ expect(screen.getByTestId("fb")).toBeTruthy();
54
+ });
55
+
56
+ it("className 透传", () => {
57
+ const { container } = render(
58
+ <LucideIconByName name="star" className="text-primary" />
59
+ );
60
+ expect(container.querySelector("svg")?.getAttribute("class")).toContain(
61
+ "text-primary"
62
+ );
63
+ });
64
+ });
65
+
66
+ describe("IconPickerPanel", () => {
67
+ beforeEach(() => {
68
+ window.localStorage.clear();
69
+ });
70
+
71
+ it("渲染搜索框 + 全部图标网格;无最近记录时不显示最近使用小节", async () => {
72
+ render(<IconPickerPanel onPick={vi.fn()} />);
73
+ expect(
74
+ screen.getByRole("textbox", { name: /搜索/ })
75
+ ).toBeTruthy();
76
+ // tab 按钮已移除
77
+ expect(screen.queryByRole("button", { name: /最近使用/ })).toBeNull();
78
+ expect(screen.queryByRole("button", { name: /全部图标/ })).toBeNull();
79
+ // 无最近记录:不显示"最近使用"小节标题,网格 = 精选总数
80
+ expect(screen.queryByText(/最近使用/)).toBeNull();
81
+ expect(screen.getByText(/全部图标/)).toBeTruthy();
82
+ const cells = await screen.findAllByRole("button", { name: /^icon-cell-/ });
83
+ expect(cells.length).toBe(CURATED_ICONS.length);
84
+ });
85
+
86
+ it("英文搜索过滤 + Enter 选中第一个结果", async () => {
87
+ const onPick = vi.fn();
88
+ render(<IconPickerPanel onPick={onPick} />);
89
+ const input = screen.getByRole("textbox", { name: /搜索/ });
90
+ await userEvent.type(input, "kanban{Enter}");
91
+ expect(onPick).toHaveBeenCalledWith("folder-kanban");
92
+ });
93
+
94
+ it("中文搜索过滤并点击选中", async () => {
95
+ const onPick = vi.fn();
96
+ render(<IconPickerPanel onPick={onPick} />);
97
+ await userEvent.type(
98
+ screen.getByRole("textbox", { name: /搜索/ }),
99
+ "星标"
100
+ );
101
+ const cells = screen.getAllByRole("button", { name: /^icon-cell-/ });
102
+ expect(cells.length).toBeGreaterThanOrEqual(1);
103
+ await userEvent.click(cells[0]);
104
+ expect(onPick).toHaveBeenCalledWith("star");
105
+ });
106
+
107
+ it("无结果显示空态", async () => {
108
+ render(<IconPickerPanel onPick={vi.fn()} />);
109
+ await userEvent.type(
110
+ screen.getByRole("textbox", { name: /搜索/ }),
111
+ "zzzz不存在"
112
+ );
113
+ expect(screen.getByText(/未找到匹配的图标/)).toBeTruthy();
114
+ expect(screen.queryAllByRole("button", { name: /^icon-cell-/ })).toEqual([]);
115
+ });
116
+
117
+ it("有最近记录时同页显示最近使用小节(上下排列,总数 = 精选数 + 最近数)", async () => {
118
+ pushRecentIcon("star");
119
+ pushRecentIcon("clock");
120
+ render(<IconPickerPanel onPick={vi.fn()} />);
121
+ expect(screen.getByText(/最近使用/)).toBeTruthy();
122
+ expect(screen.getByText(/全部图标/)).toBeTruthy();
123
+ const cells = await screen.findAllByRole("button", { name: /^icon-cell-/ });
124
+ expect(cells.length).toBe(CURATED_ICONS.length + 2);
125
+ });
126
+
127
+ it("value 对应 cell 有选中态标识", async () => {
128
+ render(<IconPickerPanel value="star" onPick={vi.fn()} />);
129
+ const cell = screen.getByRole("button", { name: "icon-cell-star" });
130
+ expect(cell.getAttribute("data-selected")).toBe("true");
131
+ });
132
+ });
133
+
134
+ describe("LucideIconPicker(Popover 包装)", () => {
135
+ beforeEach(() => {
136
+ window.localStorage.clear();
137
+ vi.stubGlobal(
138
+ "ResizeObserver",
139
+ class {
140
+ observe() {}
141
+ unobserve() {}
142
+ disconnect() {}
143
+ }
144
+ );
145
+ });
146
+ afterEach(() => {
147
+ vi.unstubAllGlobals();
148
+ });
149
+
150
+ it("不传 children 时渲染内置微笑触发按钮(未选择占位)", async () => {
151
+ render(<LucideIconPicker value={undefined} onChange={vi.fn()} />);
152
+ const trigger = screen.getByRole("button", { name: /选择图标/ });
153
+ expect(trigger.querySelector("svg")?.getAttribute("class")).toContain(
154
+ "lucide-smile"
155
+ );
156
+ });
157
+
158
+ it("内置触发按钮在选择后回显所选图标", async () => {
159
+ render(<LucideIconPicker value="clock" onChange={vi.fn()} />);
160
+ const trigger = screen.getByRole("button", { name: /选择图标/ });
161
+ expect(trigger.querySelector("svg")?.getAttribute("class")).toContain(
162
+ "lucide-clock"
163
+ );
164
+ });
165
+
166
+ it("渲染 trigger,点击后打开面板(lazy 加载完成)", async () => {
167
+ render(
168
+ <LucideIconPicker value="star" onChange={vi.fn()}>
169
+ <button type="button">选图标</button>
170
+ </LucideIconPicker>
171
+ );
172
+ await userEvent.click(screen.getByRole("button", { name: "选图标" }));
173
+ expect(
174
+ await screen.findByRole("button", { name: "icon-cell-star" })
175
+ ).toBeTruthy();
176
+ });
177
+
178
+ it("点击图标触发 onChange 并关闭弹窗、写入最近使用", async () => {
179
+ const onChange = vi.fn();
180
+ render(
181
+ <LucideIconPicker value={undefined} onChange={onChange}>
182
+ <button type="button">选图标</button>
183
+ </LucideIconPicker>
184
+ );
185
+ await userEvent.click(screen.getByRole("button", { name: "选图标" }));
186
+ await userEvent.click(
187
+ await screen.findByRole("button", { name: "icon-cell-clock" })
188
+ );
189
+ expect(onChange).toHaveBeenCalledWith("clock");
190
+ await waitFor(() =>
191
+ expect(
192
+ screen.queryByRole("button", { name: "icon-cell-clock" })
193
+ ).toBeNull()
194
+ );
195
+ expect(getRecentIcons()).toEqual(["clock"]);
196
+ });
197
+ });