@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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
import { GitPullRequestArrow, CircleFadingArrowUp, Repeat2, CalendarPlus2, Hash, MessageCircleCheck, RefreshCcwDot, CircleFadingPlus, CircleArrowUp, CircleArrowRight, CircleArrowDown, CircleSlash, CircleDashed, CircleCheckBig, LoaderCircle, CheckCircle2, XCircle, FilePlus, FilePenLine, MessageSquareText, Flag, UserRound, Text, Calendar, CalendarCheck, Tags, TextInitial, Building2, GitBranch, ListTree, ArrowLeftRight, Link2, Paperclip, BookOpen, type LucideIcon } from "lucide-react";
|
|
18
|
+
import { GitPullRequestArrow, CircleFadingArrowUp, Repeat, Repeat2, CalendarPlus2, Hash, MessageCircleCheck, RefreshCcwDot, CircleFadingPlus, CircleArrowUp, CircleArrowRight, CircleArrowDown, CircleSlash, CircleDashed, CircleCheckBig, LoaderCircle, CheckCircle2, XCircle, FilePlus, FilePenLine, MessageSquareText, Flag, UserRound, Text, Calendar, CalendarCheck, Tags, TextInitial, Building2, GitBranch, ListTree, ArrowLeftRight, ArrowDownToDot, Link2, Paperclip, BookOpen, Unlink, Trash2, BookX, FileX, ArrowRightFromLine, ArrowLeftToLine, ArrowUpFromDot, type LucideIcon } from "lucide-react";
|
|
19
19
|
import type { DependencyEdgeType, DependencyGroup } from "./types";
|
|
20
20
|
|
|
21
21
|
export type StoryState = "backlog" | "todo" | "in_progress" | "done" | "cancelled";
|
|
@@ -103,9 +103,17 @@ export const ACTIVITY_LABEL: Record<string, string> = {
|
|
|
103
103
|
parent_removed: "activity.parent_removed",
|
|
104
104
|
child_added: "activity.child_added",
|
|
105
105
|
dependency_added: "activity.dependency_added",
|
|
106
|
+
dependency_removed: "activity.dependency_removed",
|
|
106
107
|
link_added: "activity.link_added",
|
|
108
|
+
link_updated: "activity.link_updated",
|
|
109
|
+
link_removed: "activity.link_removed",
|
|
107
110
|
attachment_added: "activity.attachment_added",
|
|
111
|
+
attachment_updated: "activity.attachment_updated",
|
|
112
|
+
attachment_removed: "activity.attachment_removed",
|
|
108
113
|
kb_linked: "activity.kb_linked",
|
|
114
|
+
kb_unlinked: "activity.kb_unlinked",
|
|
115
|
+
prd_unlinked: "activity.prd_unlinked",
|
|
116
|
+
iteration_changed: "activity.iteration_changed",
|
|
109
117
|
};
|
|
110
118
|
|
|
111
119
|
export const ACTIVITY_ICON: Record<string, LucideIcon> = {
|
|
@@ -126,9 +134,17 @@ startDate_changed: CalendarPlus2,
|
|
|
126
134
|
parent_removed: GitPullRequestArrow,
|
|
127
135
|
child_added: ListTree,
|
|
128
136
|
dependency_added: ArrowLeftRight,
|
|
137
|
+
dependency_removed: Unlink,
|
|
129
138
|
link_added: Link2,
|
|
139
|
+
link_updated: Link2,
|
|
140
|
+
link_removed: Unlink,
|
|
130
141
|
attachment_added: Paperclip,
|
|
142
|
+
attachment_updated: FilePenLine,
|
|
143
|
+
attachment_removed: Trash2,
|
|
131
144
|
kb_linked: BookOpen,
|
|
145
|
+
kb_unlinked: BookX,
|
|
146
|
+
prd_unlinked: FileX,
|
|
147
|
+
iteration_changed: Repeat,
|
|
132
148
|
};
|
|
133
149
|
|
|
134
150
|
// ====== 依赖关系常量(对齐 Plane,新模型) ======
|
|
@@ -154,6 +170,10 @@ export interface DependencyTypeOption {
|
|
|
154
170
|
edge: DependencyEdgeType;
|
|
155
171
|
/** 与 group 等价的方向标志:true = 存 (Y, X, edge) */
|
|
156
172
|
reversed: boolean;
|
|
173
|
+
/** 选择器/行内标签显示的图标(lucide 组件引用) */
|
|
174
|
+
icon: LucideIcon;
|
|
175
|
+
/** 图标旋转角度(deg),每个图标方向语义不同可单独设定;0 或不填不旋转 */
|
|
176
|
+
iconRotation: number;
|
|
157
177
|
}
|
|
158
178
|
|
|
159
179
|
export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
|
|
@@ -165,6 +185,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
|
|
|
165
185
|
tooltipLabelKey: "dependency.tooltips.blocked_by",
|
|
166
186
|
edge: "BLOCKS",
|
|
167
187
|
reversed: true,
|
|
188
|
+
icon: ArrowRightFromLine,
|
|
189
|
+
iconRotation: 0,
|
|
168
190
|
},
|
|
169
191
|
{
|
|
170
192
|
key: "starts_after",
|
|
@@ -174,6 +196,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
|
|
|
174
196
|
tooltipLabelKey: "dependency.tooltips.starts_after",
|
|
175
197
|
edge: "STARTS_BEFORE",
|
|
176
198
|
reversed: true,
|
|
199
|
+
icon: ArrowUpFromDot,
|
|
200
|
+
iconRotation: 90,
|
|
177
201
|
},
|
|
178
202
|
{
|
|
179
203
|
key: "finishes_after",
|
|
@@ -183,6 +207,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
|
|
|
183
207
|
tooltipLabelKey: "dependency.tooltips.finishes_after",
|
|
184
208
|
edge: "FINISHES_BEFORE",
|
|
185
209
|
reversed: true,
|
|
210
|
+
icon: ArrowLeftToLine,
|
|
211
|
+
iconRotation: 0,
|
|
186
212
|
},
|
|
187
213
|
{
|
|
188
214
|
key: "blocking",
|
|
@@ -192,6 +218,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
|
|
|
192
218
|
tooltipLabelKey: "dependency.tooltips.blocking",
|
|
193
219
|
edge: "BLOCKS",
|
|
194
220
|
reversed: false,
|
|
221
|
+
icon: ArrowRightFromLine,
|
|
222
|
+
iconRotation: 0,
|
|
195
223
|
},
|
|
196
224
|
{
|
|
197
225
|
key: "starts_before",
|
|
@@ -201,6 +229,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
|
|
|
201
229
|
tooltipLabelKey: "dependency.tooltips.starts_before",
|
|
202
230
|
edge: "STARTS_BEFORE",
|
|
203
231
|
reversed: false,
|
|
232
|
+
icon: ArrowUpFromDot,
|
|
233
|
+
iconRotation: 90,
|
|
204
234
|
},
|
|
205
235
|
{
|
|
206
236
|
key: "finishes_before",
|
|
@@ -210,6 +240,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
|
|
|
210
240
|
tooltipLabelKey: "dependency.tooltips.finishes_before",
|
|
211
241
|
edge: "FINISHES_BEFORE",
|
|
212
242
|
reversed: false,
|
|
243
|
+
icon: ArrowLeftToLine,
|
|
244
|
+
iconRotation: 0,
|
|
213
245
|
},
|
|
214
246
|
];
|
|
215
247
|
|
|
@@ -20,7 +20,7 @@ import { CalendarCheck, CalendarPlus2, Sprout } from "lucide-react"
|
|
|
20
20
|
import { UserAvatar } from "@allbluecn/ui"
|
|
21
21
|
import { useForm } from "@tanstack/react-form"
|
|
22
22
|
import { useTranslation } from "react-i18next"
|
|
23
|
-
import {
|
|
23
|
+
import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name"
|
|
24
24
|
import { Button } from "@/components/ui/button"
|
|
25
25
|
import { Input } from "@/components/ui/input"
|
|
26
26
|
|
|
@@ -123,10 +123,12 @@ export function CreateStoryDialog({
|
|
|
123
123
|
form.setFieldValue("startDate", initialStartDate ?? "")
|
|
124
124
|
form.setFieldValue("targetDate", "")
|
|
125
125
|
form.setFieldValue("labelIds", [])
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
if (!continuousCreate) {
|
|
127
|
+
if (initialProjectId) {
|
|
128
|
+
form.setFieldValue("projectId", initialProjectId)
|
|
129
|
+
} else {
|
|
130
|
+
ensureBuiltinProjectFn().then((p) => form.setFieldValue("projectId", p.id)).catch(() => {})
|
|
131
|
+
}
|
|
130
132
|
}
|
|
131
133
|
setDescription({ html: "", markdown: "" })
|
|
132
134
|
setIsEditorFocused(false)
|
|
@@ -176,8 +178,26 @@ export function CreateStoryDialog({
|
|
|
176
178
|
labelIds: values.labelIds,
|
|
177
179
|
},
|
|
178
180
|
})
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
if (tiptapEditorRef.current) {
|
|
182
|
+
tiptapEditorRef.current.commands.clearContent()
|
|
183
|
+
}
|
|
184
|
+
if (continuousCreate) {
|
|
185
|
+
const currentProjectId = form.state.values.projectId
|
|
186
|
+
form.setFieldValue("name", "")
|
|
187
|
+
setDescription({ html: "", markdown: "" })
|
|
188
|
+
form.setFieldValue("state", initialState ?? "backlog")
|
|
189
|
+
form.setFieldValue("priority", "none")
|
|
190
|
+
form.setFieldValue("assigneeId", "none")
|
|
191
|
+
form.setFieldValue("startDate", initialStartDate ?? "")
|
|
192
|
+
form.setFieldValue("targetDate", "")
|
|
193
|
+
form.setFieldValue("labelIds", [])
|
|
194
|
+
if (currentProjectId) {
|
|
195
|
+
form.setFieldValue("projectId", currentProjectId)
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
form.reset()
|
|
199
|
+
setDescription({ html: "", markdown: "" })
|
|
200
|
+
}
|
|
181
201
|
if (!continuousCreate) onOpenChange(false)
|
|
182
202
|
onSuccess?.(
|
|
183
203
|
{
|
|
@@ -220,7 +240,7 @@ export function CreateStoryDialog({
|
|
|
220
240
|
className="h-8 gap-1.5 border-0 bg-muted/50 px-2.5 text-sm font-medium"
|
|
221
241
|
>
|
|
222
242
|
{selectedProject ? (
|
|
223
|
-
<
|
|
243
|
+
<LucideIconByName name={selectedProject.icon} size={16} />
|
|
224
244
|
) : (
|
|
225
245
|
<Sprout className="size-4 text-muted-foreground" />
|
|
226
246
|
)}
|
|
@@ -236,7 +256,7 @@ export function CreateStoryDialog({
|
|
|
236
256
|
className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${field.value === p.id ? "bg-accent font-medium" : "hover:bg-muted"}`}
|
|
237
257
|
>
|
|
238
258
|
<span className="flex min-w-0 items-center gap-2">
|
|
239
|
-
<
|
|
259
|
+
<LucideIconByName name={p.icon} size={16} />
|
|
240
260
|
<span className="truncate">{p.name}</span>
|
|
241
261
|
</span>
|
|
242
262
|
{p.displayId && (
|
|
@@ -324,7 +344,7 @@ export function CreateStoryDialog({
|
|
|
324
344
|
<DateEditor value={field.value || null} onChange={(v) => field.handleChange(v ?? "")} label={t("create.dueDate")} icon={CalendarCheck} />
|
|
325
345
|
)} />
|
|
326
346
|
<form.Field name="labelIds" children={(field) => (
|
|
327
|
-
<LabelsEditor labelIds={field.value as string[]} labels={labels} onChange={(v) => field.handleChange(v as string[])} variant="button" />
|
|
347
|
+
<LabelsEditor labelIds={field.value as string[]} labels={labels} onChange={(v) => field.handleChange(v as string[])} variant="button" aiContext={{ name: () => ((form.state.values.name as string) ?? "").trim() }} />
|
|
328
348
|
)} />
|
|
329
349
|
</div>
|
|
330
350
|
|
|
@@ -0,0 +1,62 @@
|
|
|
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, it, expect } from "vitest";
|
|
19
|
+
import { render, screen } from "@testing-library/react";
|
|
20
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
21
|
+
import { StoryDetailCategories } from "../story-detail-toolbar/categories";
|
|
22
|
+
|
|
23
|
+
vi.mock("react-i18next", () => ({
|
|
24
|
+
useTranslation: () => ({ t: (key: string) => key }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const renderWithProviders = (ui: React.ReactElement) =>
|
|
28
|
+
render(<TooltipProvider>{ui}</TooltipProvider>);
|
|
29
|
+
|
|
30
|
+
describe("StoryDetailCategories", () => {
|
|
31
|
+
it("渲染 6 个 category button", () => {
|
|
32
|
+
renderWithProviders(<StoryDetailCategories />);
|
|
33
|
+
const buttons = screen.getAllByRole("button");
|
|
34
|
+
expect(buttons).toHaveLength(6);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("渲染 6 个 category 标签", () => {
|
|
38
|
+
renderWithProviders(<StoryDetailCategories />);
|
|
39
|
+
expect(screen.getByText("categories.prd.label")).toBeTruthy();
|
|
40
|
+
expect(screen.getByText("categories.ui.label")).toBeTruthy();
|
|
41
|
+
expect(screen.getByText("categories.bug.label")).toBeTruthy();
|
|
42
|
+
expect(screen.getByText("categories.testCase.label")).toBeTruthy();
|
|
43
|
+
expect(screen.getByText("categories.acceptance.label")).toBeTruthy();
|
|
44
|
+
expect(screen.getByText("categories.retro.label")).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("bug 和 test-case 之间有一个分隔线", () => {
|
|
48
|
+
renderWithProviders(<StoryDetailCategories />);
|
|
49
|
+
const spans = document.querySelectorAll("span");
|
|
50
|
+
const separator = Array.from(spans).find((s) =>
|
|
51
|
+
s.className.includes("w-px") && s.className.includes("h-4"),
|
|
52
|
+
);
|
|
53
|
+
expect(separator).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("bug 前后各有一个 button", () => {
|
|
57
|
+
renderWithProviders(<StoryDetailCategories />);
|
|
58
|
+
const bugBtn = screen.getByText("categories.bug.label")
|
|
59
|
+
.closest("button")!;
|
|
60
|
+
expect(bugBtn).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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, it, expect, vi } from "vitest";
|
|
19
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
20
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
21
|
+
import { StoryDetailDependencies } from "../story-detail-toolbar/dependencies";
|
|
22
|
+
|
|
23
|
+
vi.mock("react-i18next", () => ({
|
|
24
|
+
useTranslation: () => ({ t: (key: string) => key }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const renderWithProviders = (ui: React.ReactElement) =>
|
|
28
|
+
render(<TooltipProvider>{ui}</TooltipProvider>);
|
|
29
|
+
|
|
30
|
+
describe("StoryDetailDependencies", () => {
|
|
31
|
+
it("渲染 button,包含 Waypoints 图标和文本", () => {
|
|
32
|
+
renderWithProviders(
|
|
33
|
+
<StoryDetailDependencies onOpenAdd={vi.fn()} />,
|
|
34
|
+
);
|
|
35
|
+
const btn = screen.getByRole("button");
|
|
36
|
+
expect(btn).toBeTruthy();
|
|
37
|
+
expect(screen.getByText("relations.addDependency")).toBeTruthy();
|
|
38
|
+
expect(btn).toHaveAttribute("title", "relations.dependencyTitle");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("点击 button 触发 onOpenAdd", () => {
|
|
42
|
+
const onOpenAdd = vi.fn();
|
|
43
|
+
renderWithProviders(
|
|
44
|
+
<StoryDetailDependencies onOpenAdd={onOpenAdd} />,
|
|
45
|
+
);
|
|
46
|
+
fireEvent.click(screen.getByRole("button"));
|
|
47
|
+
expect(onOpenAdd).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -30,11 +30,11 @@ import {
|
|
|
30
30
|
type StoryState,
|
|
31
31
|
type StoryPriority,
|
|
32
32
|
} from "@/components/story/constants";
|
|
33
|
-
import {
|
|
33
|
+
import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
|
|
34
34
|
import { listProjectsBriefFn } from "@/server/serverFns/project";
|
|
35
35
|
import type { ActivityItem, UserBrief } from "@/components/story/types";
|
|
36
36
|
import { groupActivities, type ActivityGroup } from "@/lib/story/activity-grouper";
|
|
37
|
-
import { ArrowUpFromDot, ChevronDown, Dot, Sparkles, Layers, UserRound } from "lucide-react";
|
|
37
|
+
import { ArrowUpFromDot, ArrowRight, ChevronDown, Dot, Sparkles, Layers, UserRound } from "lucide-react";
|
|
38
38
|
import { sanitize } from "@/lib/sanitize";
|
|
39
39
|
|
|
40
40
|
function getDiffDecorator(
|
|
@@ -55,7 +55,7 @@ function getDiffDecorator(
|
|
|
55
55
|
return <Icon className="size-3 shrink-0 text-muted-foreground" />;
|
|
56
56
|
}
|
|
57
57
|
if (action === "project_transferred") {
|
|
58
|
-
return <
|
|
58
|
+
return <LucideIconByName name={projectIconName} size={12} className="shrink-0" />;
|
|
59
59
|
}
|
|
60
60
|
if (action === "assignee_changed") {
|
|
61
61
|
// 优先用用户头像,找不到时降级为通用图标
|
|
@@ -250,7 +250,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
|
|
|
250
250
|
</div>
|
|
251
251
|
</div>
|
|
252
252
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex size-5 items-center justify-center rounded-full bg-background border">
|
|
253
|
-
<
|
|
253
|
+
<ArrowRight className="size-2.5 text-muted-foreground" />
|
|
254
254
|
</div>
|
|
255
255
|
<div className="w-1/2 bg-green-50/40 dark:bg-green-950/20 p-2 pl-4">
|
|
256
256
|
<div className="flex items-center gap-1.5">
|
|
@@ -326,9 +326,16 @@ interface ActivityTimelineProps {
|
|
|
326
326
|
const OBJECT_ACTIVITY_ACTIONS: Record<string, true> = {
|
|
327
327
|
child_added: true,
|
|
328
328
|
dependency_added: true,
|
|
329
|
+
dependency_removed: true,
|
|
329
330
|
link_added: true,
|
|
331
|
+
link_updated: true,
|
|
332
|
+
link_removed: true,
|
|
330
333
|
attachment_added: true,
|
|
334
|
+
attachment_updated: true,
|
|
335
|
+
attachment_removed: true,
|
|
331
336
|
kb_linked: true,
|
|
337
|
+
kb_unlinked: true,
|
|
338
|
+
prd_unlinked: true,
|
|
332
339
|
};
|
|
333
340
|
|
|
334
341
|
function describeActivity(a: ActivityItem, t: TFunction): string {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
18
|
+
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
19
19
|
import { useTranslation } from "react-i18next";
|
|
20
20
|
import {
|
|
21
21
|
ChevronsDownUp,
|
|
@@ -45,7 +45,7 @@ import { EmojiPicker } from "./emoji-picker";
|
|
|
45
45
|
import { StoryAiToolbar } from "../ai/story-ai-toolbar";
|
|
46
46
|
import { useStoryAiModel } from "../ai/use-story-ai-model";
|
|
47
47
|
import { StateEditor, PriorityEditor, AssigneeEditor, DateEditor, LabelsEditor, ParentStoryEditor } from "../inline-editors";
|
|
48
|
-
import {
|
|
48
|
+
import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
|
|
49
49
|
import type { Editor } from "@tiptap/react";
|
|
50
50
|
import { StoryProperties } from "./story-properties";
|
|
51
51
|
import { StoryDetailToolbar } from "./story-detail-toolbar";
|
|
@@ -76,12 +76,15 @@ export function StoryDetailBody({
|
|
|
76
76
|
labels,
|
|
77
77
|
stories,
|
|
78
78
|
projects,
|
|
79
|
+
iterations,
|
|
79
80
|
relationsData,
|
|
80
81
|
onRelationsUpdate,
|
|
81
82
|
onAppendActivities,
|
|
82
83
|
onAppendComment,
|
|
83
84
|
onRemoveComment,
|
|
84
85
|
onStoryUpdate,
|
|
86
|
+
onNavigateInPanel,
|
|
87
|
+
breadcrumbItems,
|
|
85
88
|
}: {
|
|
86
89
|
story: StoryDetail;
|
|
87
90
|
users: UserBrief[];
|
|
@@ -97,8 +100,10 @@ export function StoryDetailBody({
|
|
|
97
100
|
labels?: { id: string; name: string; color: string }[];
|
|
98
101
|
/** 所有需求列表(用于父需求选择) */
|
|
99
102
|
stories?: { id: string; identifier: string; name: string; state: StoryState; projectId?: string }[];
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
/** 所有项目列表(用于父需求选择) */
|
|
104
|
+
projects?: { id: string; name: string; icon: string; displayId?: string | null }[];
|
|
105
|
+
/** 迭代列表(用于迭代选择器) */
|
|
106
|
+
iterations?: import("@/server/serverFns/iteration/schemas").IterationSummary[];
|
|
102
107
|
/** 关联数据(从路由 loader 预加载) */
|
|
103
108
|
relationsData?: RelationsData;
|
|
104
109
|
onRelationsUpdate?: (next: RelationsData | ((prev: RelationsData) => RelationsData)) => void;
|
|
@@ -106,6 +111,9 @@ export function StoryDetailBody({
|
|
|
106
111
|
onAppendComment?: (comment: CommentWithReplies) => void;
|
|
107
112
|
onRemoveComment?: (commentId: string) => void;
|
|
108
113
|
onStoryUpdate?: (s: StoryDetail) => void;
|
|
114
|
+
onNavigateInPanel?: (targetId: string, targetIdentifier?: string, relationKind?: "child" | "dependency") => void;
|
|
115
|
+
/** 内嵌面板导航面包屑(在面板内跳转时替代单层 identifier 显示) */
|
|
116
|
+
breadcrumbItems?: Array<{ identifier?: string; projectId?: string; label: string; onClick?: () => void }>;
|
|
109
117
|
}) {
|
|
110
118
|
const { t } = useTranslation("story");
|
|
111
119
|
const [name, setName] = useState(story.name);
|
|
@@ -125,6 +133,12 @@ export function StoryDetailBody({
|
|
|
125
133
|
const [expanded, setExpanded] = useState(false);
|
|
126
134
|
const [needsTruncation, setNeedsTruncation] = useState(false);
|
|
127
135
|
const [isEditorFocused, setIsEditorFocused] = useState(false);
|
|
136
|
+
// 描述是否为空(跟随编辑器 onChange 实时更新)。不能用 descContent.markdown 判断——
|
|
137
|
+
// 编辑会话期间 onChange 只进 pendingDescRef(防抖保存),descContent 不更新,
|
|
138
|
+
// 否则输入文字后失焦仍会显示「点击添加需求描述」占位并与文字重合
|
|
139
|
+
const [isDescEmpty, setIsDescEmpty] = useState(
|
|
140
|
+
!(parsedDesc.markdown ?? "").trim(),
|
|
141
|
+
);
|
|
128
142
|
const [reactions, setReactions] = useState<ReactionGroup[]>([]);
|
|
129
143
|
const pendingReactionsRef = useRef<Set<string>>(new Set());
|
|
130
144
|
useEffect(() => {
|
|
@@ -291,6 +305,7 @@ export function StoryDetailBody({
|
|
|
291
305
|
// 兜底防抖避免用户长时间不操作也最终落盘(防崩溃丢数据)。
|
|
292
306
|
const handleDescriptionChange = useCallback((content: { html: string; markdown: string }) => {
|
|
293
307
|
pendingDescRef.current = content;
|
|
308
|
+
setIsDescEmpty(!content.markdown.trim());
|
|
294
309
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
295
310
|
saveTimerRef.current = setTimeout(
|
|
296
311
|
() => flushDescriptionRef.current(),
|
|
@@ -317,6 +332,7 @@ export function StoryDetailBody({
|
|
|
317
332
|
}
|
|
318
333
|
const newContent = { html: newMarkdown, markdown: newMarkdown };
|
|
319
334
|
setDescContent(newContent);
|
|
335
|
+
setIsDescEmpty(!newMarkdown.trim());
|
|
320
336
|
const json = serializeStoryDescription(newMarkdown, newMarkdown);
|
|
321
337
|
lastPersistedRef.current = json;
|
|
322
338
|
updateField("description", json);
|
|
@@ -345,38 +361,62 @@ export function StoryDetailBody({
|
|
|
345
361
|
<div className="flex items-start justify-between gap-4 px-6">
|
|
346
362
|
<div className="flex-1 space-y-4">
|
|
347
363
|
<div className="flex items-center gap-2">
|
|
348
|
-
{
|
|
349
|
-
<
|
|
350
|
-
|
|
351
|
-
<
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
364
|
+
{breadcrumbItems && breadcrumbItems.length > 0 ? (
|
|
365
|
+
<div className="flex items-center gap-1.5 text-sm font-mono text-muted-foreground">
|
|
366
|
+
{breadcrumbItems.map((item, i) => (
|
|
367
|
+
<React.Fragment key={i}>
|
|
368
|
+
{i > 0 && <span className="text-muted-foreground/40 px-1">/</span>}
|
|
369
|
+
<button
|
|
370
|
+
type="button"
|
|
371
|
+
onClick={item.onClick}
|
|
372
|
+
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
|
|
373
|
+
>
|
|
374
|
+
<LucideIconByName
|
|
375
|
+
name={projects?.find((p) => p.id === item.projectId)?.icon ?? project?.icon}
|
|
376
|
+
size={16}
|
|
377
|
+
className="shrink-0 text-muted-foreground"
|
|
378
|
+
/>
|
|
379
|
+
{item.identifier}
|
|
380
|
+
</button>
|
|
381
|
+
</React.Fragment>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
) : (
|
|
385
|
+
<>
|
|
386
|
+
{project && (
|
|
387
|
+
<Tooltip>
|
|
388
|
+
<TooltipTrigger asChild>
|
|
389
|
+
<span className="flex cursor-default items-center">
|
|
390
|
+
<LucideIconByName name={project.icon} size={16} className="shrink-0 text-muted-foreground" />
|
|
391
|
+
</span>
|
|
392
|
+
</TooltipTrigger>
|
|
393
|
+
<TooltipContent>
|
|
394
|
+
<div className="flex flex-col">
|
|
395
|
+
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{t("detail.belongsTo")}</span>
|
|
396
|
+
<span>{project.name}</span>
|
|
397
|
+
</div>
|
|
398
|
+
</TooltipContent>
|
|
399
|
+
</Tooltip>
|
|
400
|
+
)}
|
|
401
|
+
{identifier && (
|
|
402
|
+
<Tooltip>
|
|
403
|
+
<TooltipTrigger asChild>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
onClick={() => {
|
|
407
|
+
navigator.clipboard.writeText(identifier)
|
|
408
|
+
.then(() => toast.success(t("detail.copied", { identifier })))
|
|
409
|
+
.catch(() => toast.error(t("detail.copyFailed")));
|
|
410
|
+
}}
|
|
411
|
+
className="text-sm font-mono text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
412
|
+
>
|
|
413
|
+
{identifier}
|
|
414
|
+
</button>
|
|
415
|
+
</TooltipTrigger>
|
|
416
|
+
<TooltipContent>{t("detail.clickToCopy")}</TooltipContent>
|
|
417
|
+
</Tooltip>
|
|
418
|
+
)}
|
|
419
|
+
</>
|
|
380
420
|
)}
|
|
381
421
|
</div>
|
|
382
422
|
{/* {saving && <span className="text-xs text-muted-foreground">保存中...</span>} */}
|
|
@@ -389,7 +429,7 @@ export function StoryDetailBody({
|
|
|
389
429
|
</div>
|
|
390
430
|
</div>
|
|
391
431
|
{metaBar}
|
|
392
|
-
<div ref={editorContainerRef} className={`relative notion-full-width${!isEditorFocused &&
|
|
432
|
+
<div ref={editorContainerRef} className={`relative notion-full-width${!isEditorFocused && isDescEmpty ? " story-editor-no-placeholder" : ""}`} style={{ "--notion-content-width": "1fr", "--notion-margin-width": "0px" } as React.CSSProperties} onPointerDown={onEditorPointerDown}>
|
|
393
433
|
<div className={!expanded && needsTruncation ? "max-h-[90px] overflow-hidden" : ""}>
|
|
394
434
|
<Suspense fallback={
|
|
395
435
|
<div className="flex flex-col space-y-2 p-3 min-h-24">
|
|
@@ -411,7 +451,7 @@ export function StoryDetailBody({
|
|
|
411
451
|
/>
|
|
412
452
|
</Suspense>
|
|
413
453
|
</div>
|
|
414
|
-
|
|
454
|
+
{!isEditorFocused && isDescEmpty && (
|
|
415
455
|
<div className="pointer-events-none absolute top-3 left-6 text-sm text-muted-foreground select-none">
|
|
416
456
|
{t("create.addDescription")}
|
|
417
457
|
</div>
|
|
@@ -549,6 +589,7 @@ export function StoryDetailBody({
|
|
|
549
589
|
users={users}
|
|
550
590
|
projects={projects?.map((p) => ({ ...p, displayId: p.displayId ?? null }))}
|
|
551
591
|
labels={labels}
|
|
592
|
+
onNavigateInPanel={onNavigateInPanel}
|
|
552
593
|
/>
|
|
553
594
|
</div>
|
|
554
595
|
)}
|
|
@@ -556,15 +597,25 @@ export function StoryDetailBody({
|
|
|
556
597
|
<StoryProperties
|
|
557
598
|
story={{
|
|
558
599
|
id: story.id,
|
|
600
|
+
name: story.name,
|
|
601
|
+
description: story.description,
|
|
559
602
|
parentId: story.parentId,
|
|
560
603
|
parent: story.parent,
|
|
561
604
|
labels: story.labels,
|
|
562
605
|
projectId: story.projectId,
|
|
606
|
+
iteration: story.iteration ?? null,
|
|
563
607
|
}}
|
|
564
608
|
labels={labels}
|
|
565
609
|
projects={projects}
|
|
610
|
+
iterations={iterations}
|
|
566
611
|
onParentChange={updateParent}
|
|
567
612
|
onLabelsChange={updateLabels}
|
|
613
|
+
onIterationChange={(it) => {
|
|
614
|
+
if (onStoryUpdate) {
|
|
615
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
616
|
+
onStoryUpdate({ ...story, iteration: it } as any);
|
|
617
|
+
}
|
|
618
|
+
}}
|
|
568
619
|
onActivities={onAppendActivities}
|
|
569
620
|
/>
|
|
570
621
|
|