@allbluecn/web-app 0.5.0 → 0.6.0
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 +239 -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/knowledge/knowledge-toolbar.tsx +124 -1
- 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 +43 -1
- package/src/components/story/create-story-dialog.tsx +42 -16
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- 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 +26 -4
- package/src/components/story/detail/story-detail-body.tsx +90 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
- package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
- 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 +41 -63
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +192 -0
- 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 +14 -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/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +96 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- 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 +78 -7
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- 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/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -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/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- 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/knowledge.ts +50 -4
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +45 -2
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- 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-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
- package/src/server/serverFns/story/index.ts +9 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +372 -51
- package/src/server/serverFns/story/story-attachments.ts +32 -45
- 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 +53 -6
- package/src/server/serverFns/story/story-dependencies.ts +158 -5
- package/src/server/serverFns/story/story-links.ts +43 -7
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +38 -0
- package/src/styles/globals.css +25 -0
- package/src/types/project.ts +5 -5
- package/vite.shared.ts +2 -0
- 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/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/lib/project-mock.ts +0 -156
|
@@ -19,10 +19,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
19
19
|
import { useTranslation } from "react-i18next";
|
|
20
20
|
import { useDebouncedCallback, SEARCH_DEBOUNCE_MS } from "@/lib/pacer";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
|
-
import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag
|
|
22
|
+
import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag } from "lucide-react";
|
|
23
23
|
import { Badge } from "@/components/ui/badge";
|
|
24
24
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
25
|
-
import {
|
|
25
|
+
import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
|
|
26
26
|
import { Button } from "@/components/ui/button";
|
|
27
27
|
import { Input } from "@/components/ui/input";
|
|
28
28
|
import {
|
|
@@ -50,11 +50,11 @@ import {
|
|
|
50
50
|
STATE_LABEL,
|
|
51
51
|
STORY_STATES,
|
|
52
52
|
STORY_PRIORITIES,
|
|
53
|
-
DEPENDENCY_TYPE_OPTIONS,
|
|
54
53
|
type StoryState,
|
|
55
54
|
type DependencyTypeOption,
|
|
56
55
|
} from "../constants";
|
|
57
56
|
import { PriorityIcon } from "../priority-icon";
|
|
57
|
+
import { DependencyTypeSelect } from "./dependency-type-select";
|
|
58
58
|
|
|
59
59
|
interface ParentStoryItem {
|
|
60
60
|
id: string;
|
|
@@ -63,6 +63,7 @@ interface ParentStoryItem {
|
|
|
63
63
|
state: string;
|
|
64
64
|
priority: string;
|
|
65
65
|
projectId: string;
|
|
66
|
+
iteration?: { id: string; name: string } | null;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
interface StorySelectDialogProps {
|
|
@@ -73,7 +74,7 @@ interface StorySelectDialogProps {
|
|
|
73
74
|
initialProjectId?: string | null;
|
|
74
75
|
defaultSelectedParentId?: string | null;
|
|
75
76
|
defaultSelectedParentIds?: string[];
|
|
76
|
-
mode?: "parent" | "child" | "dependency";
|
|
77
|
+
mode?: "parent" | "child" | "dependency" | "iteration";
|
|
77
78
|
onSelected: (parentId: string | string[]) => void;
|
|
78
79
|
onActivities?: (activities: ActivityItem[]) => void;
|
|
79
80
|
skipTransfer?: boolean;
|
|
@@ -115,7 +116,7 @@ export function StorySelectDialog({
|
|
|
115
116
|
}: StorySelectDialogProps) {
|
|
116
117
|
const { t } = useTranslation("story");
|
|
117
118
|
const resolvedTitle = title ?? t("select.defaultTitle");
|
|
118
|
-
const isMulti = mode === "child" || mode === "dependency";
|
|
119
|
+
const isMulti = mode === "child" || mode === "dependency" || mode === "iteration";
|
|
119
120
|
const [projectFilter, setProjectFilter] = useState<string>(ALL_PROJECTS);
|
|
120
121
|
const [items, setItems] = useState<ParentStoryItem[]>([]);
|
|
121
122
|
const [search, setSearch] = useState("");
|
|
@@ -480,7 +481,7 @@ export function StorySelectDialog({
|
|
|
480
481
|
>
|
|
481
482
|
{activeFilterProject ? (
|
|
482
483
|
<span className="flex items-center gap-1.5">
|
|
483
|
-
<
|
|
484
|
+
<LucideIconByName name={activeFilterProject.icon} size={14} />
|
|
484
485
|
<span className="truncate max-w-[80px]">{activeFilterProject.name}</span>
|
|
485
486
|
</span>
|
|
486
487
|
) : (
|
|
@@ -524,7 +525,7 @@ export function StorySelectDialog({
|
|
|
524
525
|
}`}
|
|
525
526
|
>
|
|
526
527
|
<span className="flex min-w-0 items-center gap-2">
|
|
527
|
-
<
|
|
528
|
+
<LucideIconByName name={p.icon} size={14} />
|
|
528
529
|
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
|
529
530
|
</span>
|
|
530
531
|
{isActive && <Check className="size-3.5 shrink-0 text-primary" />}
|
|
@@ -562,8 +563,9 @@ export function StorySelectDialog({
|
|
|
562
563
|
const owner = projectById.get(story.projectId);
|
|
563
564
|
return (
|
|
564
565
|
<li key={story.id}>
|
|
565
|
-
<
|
|
566
|
-
|
|
566
|
+
<div
|
|
567
|
+
role="button"
|
|
568
|
+
tabIndex={0}
|
|
567
569
|
onClick={() => {
|
|
568
570
|
if (isMulti) {
|
|
569
571
|
setSelectedIds((prev) =>
|
|
@@ -575,7 +577,21 @@ export function StorySelectDialog({
|
|
|
575
577
|
setSelectedIds([story.id]);
|
|
576
578
|
}
|
|
577
579
|
}}
|
|
578
|
-
|
|
580
|
+
onKeyDown={(e) => {
|
|
581
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
582
|
+
e.preventDefault();
|
|
583
|
+
if (isMulti) {
|
|
584
|
+
setSelectedIds((prev) =>
|
|
585
|
+
prev.includes(story.id)
|
|
586
|
+
? prev.filter((id) => id !== story.id)
|
|
587
|
+
: [...prev, story.id],
|
|
588
|
+
);
|
|
589
|
+
} else {
|
|
590
|
+
setSelectedIds([story.id]);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}}
|
|
594
|
+
className={`flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
|
579
595
|
isSelected
|
|
580
596
|
? "bg-accent ring-1 ring-primary/30"
|
|
581
597
|
: "hover:bg-muted"
|
|
@@ -606,9 +622,14 @@ export function StorySelectDialog({
|
|
|
606
622
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
607
623
|
{story.identifier}
|
|
608
624
|
</span>
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
625
|
+
<span className="min-w-0 flex-1 truncate">
|
|
626
|
+
{story.name}
|
|
627
|
+
</span>
|
|
628
|
+
{mode === "iteration" && story.iteration && (
|
|
629
|
+
<span className="shrink-0 text-[10px] text-muted-foreground">
|
|
630
|
+
{t("iterations:editor.currentIteration")}: {story.iteration.name}
|
|
631
|
+
</span>
|
|
632
|
+
)}
|
|
612
633
|
{owner && (
|
|
613
634
|
<span className="shrink-0 rounded-sm bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
614
635
|
{owner.name}
|
|
@@ -617,7 +638,7 @@ export function StorySelectDialog({
|
|
|
617
638
|
{!isMulti && isSelected && (
|
|
618
639
|
<Check className="size-4 shrink-0 text-primary" />
|
|
619
640
|
)}
|
|
620
|
-
</
|
|
641
|
+
</div>
|
|
621
642
|
</li>
|
|
622
643
|
);
|
|
623
644
|
})}
|
|
@@ -660,55 +681,12 @@ export function StorySelectDialog({
|
|
|
660
681
|
{selectedCount > 0 ? t("select.selectedCount", { count: selectedCount }) : t("select.pleaseSelect")}
|
|
661
682
|
</span>
|
|
662
683
|
{mode === "dependency" && (
|
|
663
|
-
<
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
"h-9 gap-1.5",
|
|
670
|
-
!dependencyType && "text-muted-foreground",
|
|
671
|
-
)}
|
|
672
|
-
>
|
|
673
|
-
<Waypoints className="size-3.5" />
|
|
674
|
-
{dependencyType
|
|
675
|
-
? t(
|
|
676
|
-
DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === dependencyType)!
|
|
677
|
-
.optionLabelKey,
|
|
678
|
-
)
|
|
679
|
-
: t("dependency.typePlaceholder")}
|
|
680
|
-
</Button>
|
|
681
|
-
</PopoverTrigger>
|
|
682
|
-
<PopoverContent align="start" className="w-80 p-1">
|
|
683
|
-
{(["dependsOn", "dependents"] as const).map((group, gi) => (
|
|
684
|
-
<div key={group} className={cn(gi > 0 && "mt-1 border-t pt-1")}>
|
|
685
|
-
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
|
686
|
-
{t(`dependency.optionGroups.${group}`)}
|
|
687
|
-
</p>
|
|
688
|
-
{DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === group).map((opt) => {
|
|
689
|
-
const checked = dependencyType === opt.key;
|
|
690
|
-
return (
|
|
691
|
-
<button
|
|
692
|
-
key={opt.key}
|
|
693
|
-
type="button"
|
|
694
|
-
onClick={() => {
|
|
695
|
-
onDependencyTypeChange?.(opt.key);
|
|
696
|
-
setDepPopoverOpen(false);
|
|
697
|
-
}}
|
|
698
|
-
className={cn(
|
|
699
|
-
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted",
|
|
700
|
-
checked && "font-medium",
|
|
701
|
-
)}
|
|
702
|
-
>
|
|
703
|
-
<span className="flex-1">{t(opt.optionLabelKey)}</span>
|
|
704
|
-
{checked && <Check className="size-3.5 shrink-0 text-primary" />}
|
|
705
|
-
</button>
|
|
706
|
-
);
|
|
707
|
-
})}
|
|
708
|
-
</div>
|
|
709
|
-
))}
|
|
710
|
-
</PopoverContent>
|
|
711
|
-
</Popover>
|
|
684
|
+
<DependencyTypeSelect
|
|
685
|
+
value={dependencyType}
|
|
686
|
+
onChange={(key) => onDependencyTypeChange?.(key)}
|
|
687
|
+
open={depPopoverOpen}
|
|
688
|
+
onOpenChange={setDepPopoverOpen}
|
|
689
|
+
/>
|
|
712
690
|
)}
|
|
713
691
|
</div>
|
|
714
692
|
)}
|
|
@@ -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 { useEffect, useState } from "react";
|
|
18
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
19
19
|
import { useTranslation } from "react-i18next";
|
|
20
20
|
import { useHotkey } from "@tanstack/react-hotkeys";
|
|
21
21
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
@@ -33,11 +33,12 @@ import {
|
|
|
33
33
|
getLinkedPrdsFn,
|
|
34
34
|
getLinkedDocsFn,
|
|
35
35
|
} from "@/server/serverFns/story";
|
|
36
|
+
import { listIterationsFn } from "@/server/serverFns/iteration";
|
|
36
37
|
import type { StoryDetail, UserBrief, CommentWithReplies, ActivityItem, RelationsData } from "./types";
|
|
37
38
|
import type { StoryState } from "./constants";
|
|
38
39
|
import { StoryDetailBody } from "./detail/story-detail-body";
|
|
39
40
|
import { StoryPanelHeader } from "./story-panel-header";
|
|
40
|
-
import type { PeekMode } from "./story-panel-header";
|
|
41
|
+
import type { PeekMode, PeekRelationKind, PeekBreadcrumbItem } from "./story-panel-header";
|
|
41
42
|
|
|
42
43
|
export type { PeekMode };
|
|
43
44
|
|
|
@@ -70,6 +71,13 @@ interface StoryPeekPanelProps {
|
|
|
70
71
|
onTransferred?: () => void;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
interface PeekLocation {
|
|
75
|
+
storyId: string;
|
|
76
|
+
identifier?: string;
|
|
77
|
+
projectId?: string;
|
|
78
|
+
relationKind: PeekRelationKind;
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
interface PeekData {
|
|
74
82
|
story: StoryDetail;
|
|
75
83
|
comments: CommentWithReplies[];
|
|
@@ -77,6 +85,7 @@ interface PeekData {
|
|
|
77
85
|
labels: { id: string; name: string; color: string }[];
|
|
78
86
|
stories: { id: string; identifier: string; name: string; state: StoryState }[];
|
|
79
87
|
relationsData: RelationsData;
|
|
88
|
+
iterations: import("@/server/serverFns/iteration/schemas").IterationSummary[];
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
export function StoryPeekPanel({
|
|
@@ -100,25 +109,61 @@ export function StoryPeekPanel({
|
|
|
100
109
|
const { t } = useTranslation("story");
|
|
101
110
|
const [data, setData] = useState<PeekData | null>(null);
|
|
102
111
|
const [error, setError] = useState<string | null>(null);
|
|
112
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
113
|
+
const [history, setHistory] = useState<PeekLocation[]>([]);
|
|
114
|
+
const [rootIdentifier, setRootIdentifier] = useState<string | undefined>();
|
|
115
|
+
const [rootProjectId, setRootProjectId] = useState<string | undefined>();
|
|
116
|
+
|
|
117
|
+
const effectiveStoryId = history.length > 0 ? history[history.length - 1].storyId : storyId;
|
|
118
|
+
|
|
119
|
+
const onNavigateInPanel = useCallback((targetId: string, targetIdentifier?: string, relationKind: PeekRelationKind = "child") => {
|
|
120
|
+
setHistory((prev) => {
|
|
121
|
+
const currentId = prev.length > 0 ? prev[prev.length - 1].storyId : storyId;
|
|
122
|
+
return [...prev, { storyId: targetId, identifier: targetIdentifier, relationKind, parentId: currentId }];
|
|
123
|
+
});
|
|
124
|
+
}, [storyId]);
|
|
125
|
+
|
|
126
|
+
const onBackInPanel = useCallback(() => {
|
|
127
|
+
setHistory((prev) => prev.slice(0, -1));
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
setHistory([]);
|
|
132
|
+
}, [storyId]);
|
|
103
133
|
|
|
104
134
|
useEffect(() => {
|
|
105
135
|
let cancelled = false;
|
|
106
|
-
setData(null);
|
|
107
136
|
setError(null);
|
|
137
|
+
// 跳转时保留旧内容(不 setData(null)):同步卸载含 TipTap 等重节点的详情树会
|
|
138
|
+
// 阻塞主线程,导致顶部 MorphIcon 关闭/返回按钮的形变动画掉帧卡顿。
|
|
139
|
+
// 改为半透明遮罩过渡,新数据到达后经 key 变化整体替换。
|
|
140
|
+
setRefreshing(data !== null);
|
|
108
141
|
(async () => {
|
|
109
142
|
try {
|
|
110
|
-
const story = await getStoryDetailFn({ data: { storyId } });
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
const story = await getStoryDetailFn({ data: { storyId: effectiveStoryId } });
|
|
144
|
+
if (history.length === 0) {
|
|
145
|
+
setRootIdentifier(story.identifier);
|
|
146
|
+
setRootProjectId(story.projectId);
|
|
147
|
+
} else {
|
|
148
|
+
// 回填当前层 projectId:跨项目父子/依赖跳转时,面包屑各级需显示各自项目图标
|
|
149
|
+
setHistory((prev) => {
|
|
150
|
+
const last = prev[prev.length - 1];
|
|
151
|
+
if (!last || last.projectId === story.projectId) return prev;
|
|
152
|
+
return prev.map((h, i) => (i === prev.length - 1 ? { ...h, projectId: story.projectId } : h));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const [comments, activities, labels, storiesResult, childrenStories, dependencies, links, attachments, prds, docs, iterationsResult] = await Promise.all([
|
|
156
|
+
listCommentsFn({ data: { storyId: effectiveStoryId } }),
|
|
157
|
+
listActivitiesFn({ data: { storyId: effectiveStoryId, take: 50 } }),
|
|
114
158
|
listLabelsFn(),
|
|
115
159
|
listStoriesFn({ data: { projectId: story.projectId } }),
|
|
116
|
-
listChildStoriesFn({ data: { parentId:
|
|
117
|
-
getDependenciesFn({ data: { storyId } }),
|
|
118
|
-
getLinksFn({ data: { storyId } }),
|
|
119
|
-
getAttachmentsFn({ data: { storyId } }),
|
|
120
|
-
getLinkedPrdsFn({ data: { storyId } }),
|
|
121
|
-
getLinkedDocsFn({ data: { storyId } }),
|
|
160
|
+
listChildStoriesFn({ data: { parentId: effectiveStoryId } }),
|
|
161
|
+
getDependenciesFn({ data: { storyId: effectiveStoryId } }),
|
|
162
|
+
getLinksFn({ data: { storyId: effectiveStoryId } }),
|
|
163
|
+
getAttachmentsFn({ data: { storyId: effectiveStoryId } }),
|
|
164
|
+
getLinkedPrdsFn({ data: { storyId: effectiveStoryId } }),
|
|
165
|
+
getLinkedDocsFn({ data: { storyId: effectiveStoryId } }),
|
|
166
|
+
story.projectId ? listIterationsFn({ data: { projectId: story.projectId, includeArchived: false } }) : Promise.resolve({ items: [] }),
|
|
122
167
|
]);
|
|
123
168
|
const relationsData: RelationsData = {
|
|
124
169
|
hierarchy: {
|
|
@@ -131,33 +176,82 @@ export function StoryPeekPanel({
|
|
|
131
176
|
attachments,
|
|
132
177
|
knowledge: { prds, docs },
|
|
133
178
|
};
|
|
134
|
-
if (!cancelled)
|
|
179
|
+
if (!cancelled) {
|
|
180
|
+
setData({ story, comments, activities: activities.items, labels: labels, stories: storiesResult.items.map((s) => ({ id: s.id, identifier: s.identifier, name: s.name, state: s.state })), relationsData, iterations: iterationsResult.items });
|
|
181
|
+
setRefreshing(false);
|
|
182
|
+
}
|
|
135
183
|
} catch (e) {
|
|
136
|
-
if (!cancelled)
|
|
184
|
+
if (!cancelled) {
|
|
185
|
+
setError(e instanceof Error ? e.message : t("peek.loadFailed"));
|
|
186
|
+
setRefreshing(false);
|
|
187
|
+
}
|
|
137
188
|
}
|
|
138
189
|
})();
|
|
139
190
|
return () => {
|
|
140
191
|
cancelled = true;
|
|
141
192
|
};
|
|
142
|
-
}, [
|
|
193
|
+
}, [effectiveStoryId]);
|
|
143
194
|
|
|
144
195
|
useHotkey("Escape", onClose, {
|
|
145
|
-
enabled: mode === "side-peek",
|
|
196
|
+
enabled: mode === "side-peek" && history.length === 0,
|
|
146
197
|
});
|
|
147
198
|
|
|
148
199
|
const canArchive = data?.story
|
|
149
200
|
? (data.story.state === "done" || data.story.state === "cancelled") && !data.story.archived
|
|
150
201
|
: false;
|
|
151
202
|
|
|
203
|
+
const closeType: "collapse" | "close" | "back" =
|
|
204
|
+
history.length > 0
|
|
205
|
+
? "back"
|
|
206
|
+
: mode === "side-peek"
|
|
207
|
+
? "collapse"
|
|
208
|
+
: "close";
|
|
209
|
+
|
|
210
|
+
const handleClose = useCallback(() => {
|
|
211
|
+
if (history.length > 0) {
|
|
212
|
+
onBackInPanel();
|
|
213
|
+
} else {
|
|
214
|
+
onClose();
|
|
215
|
+
}
|
|
216
|
+
}, [history.length, onBackInPanel, onClose]);
|
|
217
|
+
|
|
218
|
+
const breadcrumbItems: PeekBreadcrumbItem[] = useMemo(() => {
|
|
219
|
+
const rootId = rootIdentifier;
|
|
220
|
+
const currentId = data?.story.identifier;
|
|
221
|
+
const items: PeekBreadcrumbItem[] = [];
|
|
222
|
+
|
|
223
|
+
if (history.length === 0) {
|
|
224
|
+
return items;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const entry = history[history.length - 1];
|
|
228
|
+
const parentLabel = entry.relationKind === "child" ? t("peek.parentStory") : t("peek.currentStory");
|
|
229
|
+
const childLabel = entry.relationKind === "child" ? t("peek.childStory") : t("peek.dependentStory");
|
|
230
|
+
|
|
231
|
+
items.push({
|
|
232
|
+
identifier: history.length >= 2 ? history[history.length - 2].identifier ?? rootId : rootId,
|
|
233
|
+
projectId: history.length >= 2 ? history[history.length - 2].projectId ?? rootProjectId : rootProjectId,
|
|
234
|
+
label: parentLabel,
|
|
235
|
+
onClick: () => onBackInPanel(),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
items.push({
|
|
239
|
+
identifier: currentId,
|
|
240
|
+
projectId: data?.story.projectId ?? undefined,
|
|
241
|
+
label: childLabel,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return items;
|
|
245
|
+
}, [history, rootIdentifier, rootProjectId, data?.story.identifier, data?.story.projectId, t, onBackInPanel]);
|
|
246
|
+
|
|
152
247
|
const header = (
|
|
153
248
|
<StoryPanelHeader
|
|
154
|
-
closeType={
|
|
155
|
-
onClose={
|
|
249
|
+
closeType={closeType}
|
|
250
|
+
onClose={handleClose}
|
|
156
251
|
onFullscreen={onFullscreen}
|
|
157
252
|
mode={mode}
|
|
158
253
|
onModeChange={onModeChange}
|
|
159
254
|
storyId={storyId}
|
|
160
|
-
identifier={data?.story.identifier}
|
|
161
255
|
canArchive={canArchive}
|
|
162
256
|
onPrevious={onPrevious}
|
|
163
257
|
onNext={onNext}
|
|
@@ -172,7 +266,10 @@ export function StoryPeekPanel({
|
|
|
172
266
|
);
|
|
173
267
|
|
|
174
268
|
const body = (
|
|
175
|
-
<div
|
|
269
|
+
<div
|
|
270
|
+
className={`flex min-h-0 flex-1 flex-col overflow-y-auto px-8 transition-opacity duration-200${refreshing ? " pointer-events-none opacity-50" : ""}`}
|
|
271
|
+
style={{ scrollbarGutter: "stable" } as React.CSSProperties}
|
|
272
|
+
>
|
|
176
273
|
|
|
177
274
|
{error && <div className="w-full p-6 text-sm text-destructive">{t("peek.loadFailed")}: {error}</div>}
|
|
178
275
|
{!error && !data && (
|
|
@@ -230,12 +327,15 @@ export function StoryPeekPanel({
|
|
|
230
327
|
labels={data.labels}
|
|
231
328
|
stories={data.stories}
|
|
232
329
|
projects={projects}
|
|
330
|
+
iterations={data.iterations}
|
|
233
331
|
relationsData={data.relationsData}
|
|
234
332
|
onRelationsUpdate={(next) => setData((d) => d ? { ...d, relationsData: typeof next === "function" ? next(d.relationsData) : next } : d)}
|
|
235
333
|
onAppendActivities={(items) => setData((d) => d ? { ...d, activities: [...d.activities, ...items] } : d)}
|
|
236
334
|
onAppendComment={(c) => setData((d) => d ? { ...d, comments: [...d.comments, c] } : d)}
|
|
237
335
|
onRemoveComment={(id) => setData((d) => d ? { ...d, comments: d.comments.filter((c) => c.id !== id) } : d)}
|
|
238
336
|
onStoryUpdate={(s) => setData((d) => d ? { ...d, story: s } : d)}
|
|
337
|
+
onNavigateInPanel={onNavigateInPanel}
|
|
338
|
+
breadcrumbItems={breadcrumbItems}
|
|
239
339
|
/>
|
|
240
340
|
)}
|
|
241
341
|
</div>
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
// jsdom 缺少 ResizeObserver,Radix Dialog 依赖它
|
|
19
|
+
const MockResizeObserver = class ResizeObserver {
|
|
20
|
+
observe() {}
|
|
21
|
+
unobserve() {}
|
|
22
|
+
disconnect() {}
|
|
23
|
+
};
|
|
24
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
25
|
+
(globalThis as any).ResizeObserver = MockResizeObserver;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
29
|
+
import { render, screen, fireEvent, waitFor, type RenderResult } from "@testing-library/react";
|
|
30
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
31
|
+
|
|
32
|
+
vi.mock("sonner", () => ({
|
|
33
|
+
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const fns = vi.hoisted(() => ({ uploadFetch: vi.fn() }));
|
|
37
|
+
|
|
38
|
+
// 上传走 /api/attachments/upload(multipart fetch),mock 全局 fetch
|
|
39
|
+
vi.stubGlobal("fetch", fns.uploadFetch);
|
|
40
|
+
|
|
41
|
+
function uploadResponse(body: Record<string, unknown>) {
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
json: () => Promise.resolve(body),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
import { AttachmentManageDialog } from "../attachment-manage-dialog";
|
|
49
|
+
import type { StoryAttachmentItem } from "../../types";
|
|
50
|
+
|
|
51
|
+
const ATTS: StoryAttachmentItem[] = [
|
|
52
|
+
{ id: "a1", fileName: "设计稿.png", fileSize: 2048, mimeType: "image/png", storageUrl: "/mock-storage/1" },
|
|
53
|
+
{ id: "a2", fileName: "说明.pdf", fileSize: 1048576, mimeType: "application/pdf", storageUrl: "/mock-storage/2" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function setup(): RenderResult & { onUpdate: ReturnType<typeof vi.fn>; onOpenChange: ReturnType<typeof vi.fn> } {
|
|
57
|
+
const onUpdate = vi.fn();
|
|
58
|
+
const onOpenChange = vi.fn();
|
|
59
|
+
const view = render(
|
|
60
|
+
<TooltipProvider>
|
|
61
|
+
<AttachmentManageDialog
|
|
62
|
+
open
|
|
63
|
+
onOpenChange={onOpenChange}
|
|
64
|
+
storyId="s1"
|
|
65
|
+
data={ATTS}
|
|
66
|
+
onUpdate={onUpdate}
|
|
67
|
+
/>
|
|
68
|
+
</TooltipProvider>,
|
|
69
|
+
);
|
|
70
|
+
return { ...view, onUpdate, onOpenChange };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getFileInput() {
|
|
74
|
+
return document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stageFile(file: File) {
|
|
78
|
+
fireEvent.change(getFileInput(), { target: { files: [file] } });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
fns.uploadFetch.mockResolvedValue(
|
|
84
|
+
uploadResponse({
|
|
85
|
+
id: "a3",
|
|
86
|
+
fileName: "新文件.txt",
|
|
87
|
+
fileSize: 5,
|
|
88
|
+
mimeType: "text/plain",
|
|
89
|
+
storageUrl: "/api/attachments/a3/content",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("AttachmentManageDialog", () => {
|
|
95
|
+
it("不渲染已有附件列表,仅保留上传区", () => {
|
|
96
|
+
setup();
|
|
97
|
+
expect(screen.queryByText("设计稿.png")).not.toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText("点击或拖拽上传")).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("选择文件后先在弹窗内预览(暂存),点击确认上传才发起 POST", async () => {
|
|
102
|
+
setup();
|
|
103
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
104
|
+
stageFile(file);
|
|
105
|
+
// 暂存预览:文件名 + 移除按钮出现
|
|
106
|
+
expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
|
|
107
|
+
expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
|
|
108
|
+
// 未确认前不发起上传
|
|
109
|
+
expect(fns.uploadFetch).not.toHaveBeenCalled();
|
|
110
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
111
|
+
await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
|
|
112
|
+
const [url, init] = fns.uploadFetch.mock.calls[0];
|
|
113
|
+
expect(url).toBe("/api/attachments/upload");
|
|
114
|
+
expect(init.method).toBe("POST");
|
|
115
|
+
const formData = init.body as FormData;
|
|
116
|
+
expect(formData.get("storyId")).toBe("s1");
|
|
117
|
+
expect(formData.get("file")).toBeInstanceOf(File);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("确认上传成功后回调 onUpdate(含内容路由 URL)并关闭弹窗", async () => {
|
|
121
|
+
const { onUpdate, onOpenChange } = setup();
|
|
122
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
123
|
+
stageFile(file);
|
|
124
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
125
|
+
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
|
126
|
+
const added = onUpdate.mock.calls[0][0][0];
|
|
127
|
+
expect(added.storageUrl).toBe("/api/attachments/a3/content");
|
|
128
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("描述随确认上传提交到 FormData", async () => {
|
|
132
|
+
setup();
|
|
133
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
134
|
+
stageFile(file);
|
|
135
|
+
fireEvent.change(screen.getByPlaceholderText("补充说明…"), { target: { value: "测试描述" } });
|
|
136
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
137
|
+
await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
|
|
138
|
+
const formData = fns.uploadFetch.mock.calls[0][1].body as FormData;
|
|
139
|
+
expect(formData.get("description")).toBe("测试描述");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("上传接口报错时 toast 提示且不回调 onUpdate", async () => {
|
|
143
|
+
const { toast } = await import("sonner");
|
|
144
|
+
fns.uploadFetch.mockResolvedValue({ ok: false, json: () => Promise.resolve({ error: "文件大小需在 10MB 以内" }) });
|
|
145
|
+
const { onUpdate } = setup();
|
|
146
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
147
|
+
stageFile(file);
|
|
148
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
149
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
|
150
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("超过 10MB 的文件被拒绝且不入暂存列表", async () => {
|
|
154
|
+
const { toast } = await import("sonner");
|
|
155
|
+
setup();
|
|
156
|
+
const big = new File([new ArrayBuffer(11 * 1024 * 1024)], "大文件.zip", { type: "application/zip" });
|
|
157
|
+
stageFile(big);
|
|
158
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
|
159
|
+
expect(fns.uploadFetch).not.toHaveBeenCalled();
|
|
160
|
+
expect(screen.queryByText("大文件.zip")).not.toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("取消按钮关闭弹窗", () => {
|
|
164
|
+
const { onOpenChange } = setup();
|
|
165
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
166
|
+
stageFile(file);
|
|
167
|
+
expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
|
|
168
|
+
fireEvent.click(screen.getByRole("button", { name: "取消" }));
|
|
169
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
170
|
+
});
|
|
171
|
+
});
|