@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.
- package/package.json +230 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +33 -1
- package/src/components/story/create-story-dialog.tsx +30 -10
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +11 -4
- package/src/components/story/detail/story-detail-body.tsx +89 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
- package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
- package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/link-manage-dialog.tsx +2 -1
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +1 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +54 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +2 -2
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
- package/src/server/serverFns/story/index.ts +3 -0
- package/src/server/serverFns/story/story-assoc.ts +36 -4
- package/src/server/serverFns/story/story-attachments.ts +20 -2
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +32 -2
- package/src/server/serverFns/story/story-dependencies.ts +123 -2
- package/src/server/serverFns/story/story-links.ts +30 -6
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/types.ts +1 -0
- package/src/types/project.ts +5 -5
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- 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 {
|
|
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: "
|
|
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", "
|
|
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-
|
|
152
|
-
<DialogHeader
|
|
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
|
|
158
|
-
<div className="flex
|
|
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
|
-
<
|
|
161
|
-
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|