@reqord/web 0.1.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/LICENSE +661 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +7 -0
- package/package.json +59 -0
- package/postcss.config.mjs +7 -0
- package/src/__tests__/components/dashboard/critical-path-display.test.tsx +129 -0
- package/src/__tests__/components/dashboard/progress-bar.test.tsx +87 -0
- package/src/__tests__/components/dashboard/project-health.test.tsx +57 -0
- package/src/__tests__/components/dashboard/warning-alert.test.tsx +75 -0
- package/src/__tests__/components/feedback/feedback-client-view.test.tsx +84 -0
- package/src/__tests__/components/feedback/feedback-filters.test.tsx +51 -0
- package/src/__tests__/components/feedback/feedback-linked-items.test.tsx +131 -0
- package/src/__tests__/components/feedback/feedback-list.test.tsx +49 -0
- package/src/__tests__/components/feedback/feedback-table.test.tsx +165 -0
- package/src/__tests__/components/flags/flag-badge.test.tsx +41 -0
- package/src/__tests__/components/flags/flag-list.test.tsx +51 -0
- package/src/__tests__/components/gantt/gantt-bar.test.tsx +190 -0
- package/src/__tests__/components/gantt/gantt-chart.test.tsx +141 -0
- package/src/__tests__/components/gantt/gantt-header.test.tsx +84 -0
- package/src/__tests__/components/gantt/gantt-legend.test.tsx +52 -0
- package/src/__tests__/components/graph/dag-layout.test.ts +129 -0
- package/src/__tests__/components/graph/dependency-graph.test.tsx +94 -0
- package/src/__tests__/components/graph/drilldown-breadcrumb.test.tsx +70 -0
- package/src/__tests__/components/graph/drilldown-graph.test.tsx +108 -0
- package/src/__tests__/components/graph/edge-styles.test.ts +27 -0
- package/src/__tests__/components/graph/graph-page-client.test.tsx +124 -0
- package/src/__tests__/components/graph/issue-node.test.tsx +173 -0
- package/src/__tests__/components/graph/requirement-node.test.tsx +151 -0
- package/src/__tests__/components/graph/specification-node.test.tsx +140 -0
- package/src/__tests__/components/specification/spec-tabs.test.tsx +153 -0
- package/src/__tests__/components/specification/tab-coverage.test.tsx +70 -0
- package/src/__tests__/components/specification/tab-design.test.tsx +42 -0
- package/src/__tests__/components/specification/tab-history.test.tsx +118 -0
- package/src/__tests__/components/specification/tab-issues.test.tsx +126 -0
- package/src/__tests__/components/specification/tab-research.test.tsx +42 -0
- package/src/__tests__/lib/dashboard-data.test.ts +334 -0
- package/src/__tests__/lib/drilldown-graph-data.test.ts +267 -0
- package/src/__tests__/lib/gantt-data.test.ts +299 -0
- package/src/__tests__/lib/graph-data.test.ts +309 -0
- package/src/__tests__/lib/local-feedback-repository.test.ts +74 -0
- package/src/__tests__/lib/local-specification-repository.test.ts +194 -0
- package/src/__tests__/lib/reqord-root.test.ts +31 -0
- package/src/__tests__/lib/specification-file.test.ts +63 -0
- package/src/__tests__/lib/tasks-data.test.ts +104 -0
- package/src/app/dashboard/loading.tsx +21 -0
- package/src/app/dashboard/page.tsx +50 -0
- package/src/app/error.tsx +22 -0
- package/src/app/feedback/loading.tsx +13 -0
- package/src/app/feedback/page.tsx +48 -0
- package/src/app/globals.css +2 -0
- package/src/app/graph/page.tsx +32 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/page.tsx +5 -0
- package/src/app/requirements/[id]/edit/page.tsx +40 -0
- package/src/app/requirements/[id]/loading.tsx +14 -0
- package/src/app/requirements/[id]/not-found.tsx +18 -0
- package/src/app/requirements/[id]/page.tsx +43 -0
- package/src/app/requirements/loading.tsx +13 -0
- package/src/app/requirements/new/page.tsx +14 -0
- package/src/app/requirements/page.tsx +35 -0
- package/src/app/specifications/[id]/loading.tsx +14 -0
- package/src/app/specifications/[id]/not-found.tsx +18 -0
- package/src/app/specifications/[id]/page.tsx +52 -0
- package/src/app/specifications/loading.tsx +13 -0
- package/src/app/specifications/page.tsx +42 -0
- package/src/components/dashboard/critical-path-display.tsx +76 -0
- package/src/components/dashboard/progress-bar.tsx +45 -0
- package/src/components/dashboard/progress-section.tsx +57 -0
- package/src/components/dashboard/project-health.tsx +35 -0
- package/src/components/dashboard/status-card.tsx +27 -0
- package/src/components/dashboard/status-cards.tsx +28 -0
- package/src/components/dashboard/warning-alert.tsx +33 -0
- package/src/components/dashboard/warning-alerts.tsx +24 -0
- package/src/components/feedback/feedback-badge.tsx +48 -0
- package/src/components/feedback/feedback-client-view.tsx +38 -0
- package/src/components/feedback/feedback-filters.tsx +86 -0
- package/src/components/feedback/feedback-linked-items.tsx +93 -0
- package/src/components/feedback/feedback-list.tsx +40 -0
- package/src/components/feedback/feedback-table.tsx +115 -0
- package/src/components/gantt/gantt-bar.tsx +65 -0
- package/src/components/gantt/gantt-chart.tsx +88 -0
- package/src/components/gantt/gantt-constants.ts +15 -0
- package/src/components/gantt/gantt-critical-path.tsx +38 -0
- package/src/components/gantt/gantt-group.tsx +25 -0
- package/src/components/gantt/gantt-header.tsx +47 -0
- package/src/components/gantt/gantt-legend.tsx +26 -0
- package/src/components/graph/dag-layout.ts +131 -0
- package/src/components/graph/dependency-graph.tsx +88 -0
- package/src/components/graph/drilldown-breadcrumb.tsx +35 -0
- package/src/components/graph/drilldown-graph.tsx +45 -0
- package/src/components/graph/edge-styles.ts +16 -0
- package/src/components/graph/graph-loader.tsx +25 -0
- package/src/components/graph/graph-page-client.tsx +98 -0
- package/src/components/graph/issue-node.tsx +46 -0
- package/src/components/graph/multi-level-graph.tsx +91 -0
- package/src/components/graph/requirement-node.tsx +69 -0
- package/src/components/graph/specification-node.tsx +39 -0
- package/src/components/requirement/delete-button.tsx +46 -0
- package/src/components/requirement/dependency-editor.tsx +79 -0
- package/src/components/requirement/markdown-editor.tsx +47 -0
- package/src/components/requirement/markdown-renderer.tsx +12 -0
- package/src/components/requirement/requirement-detail.tsx +228 -0
- package/src/components/requirement/requirement-form.tsx +390 -0
- package/src/components/requirement/requirement-table.tsx +203 -0
- package/src/components/requirement/requirement-tabs.tsx +65 -0
- package/src/components/requirement/success-criteria-editor.tsx +53 -0
- package/src/components/specification/spec-detail.tsx +103 -0
- package/src/components/specification/spec-tabs.tsx +66 -0
- package/src/components/specification/specification-table.tsx +193 -0
- package/src/components/specification/tab-coverage.tsx +52 -0
- package/src/components/specification/tab-design.tsx +16 -0
- package/src/components/specification/tab-history.tsx +61 -0
- package/src/components/specification/tab-issues.tsx +111 -0
- package/src/components/specification/tab-research.tsx +16 -0
- package/src/components/ui/badge.tsx +64 -0
- package/src/components/ui/nav.tsx +49 -0
- package/src/components/ui/tabs.tsx +39 -0
- package/src/lib/actions.ts +222 -0
- package/src/lib/dashboard-data.ts +224 -0
- package/src/lib/data.ts +21 -0
- package/src/lib/drilldown-graph-data.ts +98 -0
- package/src/lib/feedback-data.ts +33 -0
- package/src/lib/feedback-repository.ts +6 -0
- package/src/lib/file-system.ts +167 -0
- package/src/lib/gantt-data.ts +168 -0
- package/src/lib/get-repository.ts +43 -0
- package/src/lib/graph-data.ts +161 -0
- package/src/lib/id-generator.ts +23 -0
- package/src/lib/local-feedback-repository.ts +36 -0
- package/src/lib/local-repository.ts +78 -0
- package/src/lib/local-specification-repository.ts +61 -0
- package/src/lib/repository.ts +11 -0
- package/src/lib/reqord-root.ts +33 -0
- package/src/lib/specification-data.ts +28 -0
- package/src/lib/specification-file.ts +12 -0
- package/src/lib/specification-repository.ts +8 -0
- package/src/lib/tasks-data.ts +32 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useMemo } from "react";
|
|
4
|
+
import { ReactFlow, Background, Controls } from "@xyflow/react";
|
|
5
|
+
import { RequirementNode } from "./requirement-node";
|
|
6
|
+
import { SpecificationNode } from "./specification-node";
|
|
7
|
+
import { IssueNode } from "./issue-node";
|
|
8
|
+
import { buildDrillDownGraphData } from "@/lib/drilldown-graph-data";
|
|
9
|
+
import type { Requirement, Specification, TaskEntry } from "@reqord/shared";
|
|
10
|
+
|
|
11
|
+
interface DrillDownGraphProps {
|
|
12
|
+
requirement: Requirement;
|
|
13
|
+
specifications: Specification[];
|
|
14
|
+
tasks: TaskEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const nodeTypes = {
|
|
18
|
+
requirement: RequirementNode,
|
|
19
|
+
specification: SpecificationNode,
|
|
20
|
+
issue: IssueNode,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function DrillDownGraph({ requirement, specifications, tasks }: DrillDownGraphProps) {
|
|
24
|
+
const { nodes, edges } = useMemo(
|
|
25
|
+
() => buildDrillDownGraphData(requirement, specifications, tasks),
|
|
26
|
+
[requirement, specifications, tasks],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="h-[calc(100vh-10rem)] w-full rounded-lg border border-gray-200 bg-white">
|
|
31
|
+
<ReactFlow
|
|
32
|
+
nodes={nodes}
|
|
33
|
+
edges={edges}
|
|
34
|
+
nodeTypes={nodeTypes}
|
|
35
|
+
fitView
|
|
36
|
+
minZoom={0.1}
|
|
37
|
+
maxZoom={2}
|
|
38
|
+
proOptions={{ hideAttribution: true }}
|
|
39
|
+
>
|
|
40
|
+
<Background />
|
|
41
|
+
<Controls />
|
|
42
|
+
</ReactFlow>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const EDGE_STYLES = {
|
|
2
|
+
dependency: {
|
|
3
|
+
stroke: "#64748b",
|
|
4
|
+
strokeWidth: 2,
|
|
5
|
+
},
|
|
6
|
+
implements: {
|
|
7
|
+
stroke: "#3b82f6",
|
|
8
|
+
strokeWidth: 2,
|
|
9
|
+
strokeDasharray: "5,5",
|
|
10
|
+
},
|
|
11
|
+
tracks: {
|
|
12
|
+
stroke: "#22c55e",
|
|
13
|
+
strokeWidth: 1.5,
|
|
14
|
+
strokeDasharray: "2,2",
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
import type { Requirement } from "@reqord/shared";
|
|
5
|
+
|
|
6
|
+
const DependencyGraph = dynamic(
|
|
7
|
+
() =>
|
|
8
|
+
import("./dependency-graph").then((mod) => mod.DependencyGraph),
|
|
9
|
+
{
|
|
10
|
+
ssr: false,
|
|
11
|
+
loading: () => (
|
|
12
|
+
<div className="animate-pulse h-96 rounded-lg bg-gray-200" />
|
|
13
|
+
),
|
|
14
|
+
},
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export function GraphLoader({
|
|
18
|
+
requirements,
|
|
19
|
+
specCountMap = {},
|
|
20
|
+
}: {
|
|
21
|
+
requirements: Requirement[];
|
|
22
|
+
specCountMap?: Record<string, number>;
|
|
23
|
+
}) {
|
|
24
|
+
return <DependencyGraph requirements={requirements} specCountMap={specCountMap} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback } from "react";
|
|
4
|
+
import dynamic from "next/dynamic";
|
|
5
|
+
import { useSearchParams, useRouter } from "next/navigation";
|
|
6
|
+
import type { Requirement, Specification, TaskEntry } from "@reqord/shared";
|
|
7
|
+
import { DrillDownBreadcrumb } from "./drilldown-breadcrumb";
|
|
8
|
+
|
|
9
|
+
const DependencyGraph = dynamic(
|
|
10
|
+
() => import("./dependency-graph").then((mod) => mod.DependencyGraph),
|
|
11
|
+
{
|
|
12
|
+
ssr: false,
|
|
13
|
+
loading: () => (
|
|
14
|
+
<div className="animate-pulse h-96 rounded-lg bg-gray-200" />
|
|
15
|
+
),
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const DrillDownGraph = dynamic(
|
|
20
|
+
() => import("./drilldown-graph").then((mod) => mod.DrillDownGraph),
|
|
21
|
+
{
|
|
22
|
+
ssr: false,
|
|
23
|
+
loading: () => (
|
|
24
|
+
<div className="animate-pulse h-96 rounded-lg bg-gray-200" />
|
|
25
|
+
),
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
type GraphPageClientProps = {
|
|
30
|
+
requirements: Requirement[];
|
|
31
|
+
specifications: Specification[];
|
|
32
|
+
specCountMap: Record<string, number>;
|
|
33
|
+
tasks: TaskEntry[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function GraphPageClient({
|
|
37
|
+
requirements,
|
|
38
|
+
specifications,
|
|
39
|
+
specCountMap,
|
|
40
|
+
tasks,
|
|
41
|
+
}: GraphPageClientProps) {
|
|
42
|
+
const searchParams = useSearchParams();
|
|
43
|
+
const router = useRouter();
|
|
44
|
+
const selectedReqId = searchParams.get("req");
|
|
45
|
+
|
|
46
|
+
const handleRequirementClick = useCallback(
|
|
47
|
+
(reqId: string) => {
|
|
48
|
+
router.push(`/graph?req=${reqId}`);
|
|
49
|
+
},
|
|
50
|
+
[router],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const handleBackToOverview = useCallback(() => {
|
|
54
|
+
router.push("/graph");
|
|
55
|
+
}, [router]);
|
|
56
|
+
|
|
57
|
+
if (selectedReqId) {
|
|
58
|
+
const requirement = requirements.find((r) => r.id === selectedReqId);
|
|
59
|
+
if (!requirement) {
|
|
60
|
+
// Invalid req ID → fallback to overview
|
|
61
|
+
return (
|
|
62
|
+
<div>
|
|
63
|
+
<h1 className="mb-4 text-2xl font-bold">Dependency Graph</h1>
|
|
64
|
+
<DependencyGraph
|
|
65
|
+
requirements={requirements}
|
|
66
|
+
specCountMap={specCountMap}
|
|
67
|
+
onRequirementClick={handleRequirementClick}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const relatedSpecs = specifications.filter(
|
|
74
|
+
(s) => s.requirementId === selectedReqId,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div>
|
|
79
|
+
<DrillDownBreadcrumb
|
|
80
|
+
requirementTitle={requirement.title}
|
|
81
|
+
onBack={handleBackToOverview}
|
|
82
|
+
/>
|
|
83
|
+
<DrillDownGraph requirement={requirement} specifications={relatedSpecs} tasks={tasks} />
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div>
|
|
90
|
+
<h1 className="mb-4 text-2xl font-bold">Dependency Graph</h1>
|
|
91
|
+
<DependencyGraph
|
|
92
|
+
requirements={requirements}
|
|
93
|
+
specCountMap={specCountMap}
|
|
94
|
+
onRequirementClick={handleRequirementClick}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { memo } from "react";
|
|
4
|
+
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
|
5
|
+
|
|
6
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
7
|
+
open: "bg-yellow-200",
|
|
8
|
+
in_progress: "bg-blue-200",
|
|
9
|
+
closed: "bg-green-200",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type IssueNodeData = {
|
|
13
|
+
label: string;
|
|
14
|
+
status: string;
|
|
15
|
+
issueNumber: number;
|
|
16
|
+
issueUrl: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function IssueNodeComponent({ data }: NodeProps) {
|
|
20
|
+
const nodeData = data as IssueNodeData;
|
|
21
|
+
const bgClass = STATUS_COLORS[nodeData.status] ?? "bg-gray-200";
|
|
22
|
+
|
|
23
|
+
const handleClick = () => {
|
|
24
|
+
if (nodeData.issueUrl) {
|
|
25
|
+
window.open(nodeData.issueUrl, "_blank", "noopener,noreferrer");
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
className={`rounded-lg border shadow-sm px-3 py-2 cursor-pointer ${bgClass}`}
|
|
32
|
+
style={{ width: 180 }}
|
|
33
|
+
onClick={handleClick}
|
|
34
|
+
>
|
|
35
|
+
<Handle type="target" position={Position.Left} className="!bg-gray-400" />
|
|
36
|
+
<p className="truncate text-sm font-medium text-gray-900">{nodeData.label}</p>
|
|
37
|
+
<div className="mt-1">
|
|
38
|
+
<span className="inline-block rounded-full bg-white px-2 py-0.5 text-xs text-gray-700">
|
|
39
|
+
{nodeData.status}
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const IssueNode = memo(IssueNodeComponent);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useMemo, useCallback } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ReactFlow,
|
|
6
|
+
Background,
|
|
7
|
+
Controls,
|
|
8
|
+
MiniMap,
|
|
9
|
+
useNodesState,
|
|
10
|
+
useEdgesState,
|
|
11
|
+
type Node,
|
|
12
|
+
type Edge,
|
|
13
|
+
} from "@xyflow/react";
|
|
14
|
+
import "@xyflow/react/dist/style.css";
|
|
15
|
+
import type { MultiLevelGraphData } from "@/lib/graph-data";
|
|
16
|
+
import { RequirementNode } from "./requirement-node";
|
|
17
|
+
import { SpecificationNode } from "./specification-node";
|
|
18
|
+
import { IssueNode } from "./issue-node";
|
|
19
|
+
|
|
20
|
+
const nodeTypes = {
|
|
21
|
+
requirement: RequirementNode,
|
|
22
|
+
specification: SpecificationNode,
|
|
23
|
+
issue: IssueNode,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const EDGE_STYLES = {
|
|
27
|
+
dependency: {
|
|
28
|
+
style: { stroke: "#94a3b8", strokeWidth: 2 },
|
|
29
|
+
markerEnd: { type: "arrowclosed" as const, color: "#94a3b8" },
|
|
30
|
+
animated: false,
|
|
31
|
+
},
|
|
32
|
+
implements: {
|
|
33
|
+
style: { stroke: "#6366f1", strokeWidth: 2, strokeDasharray: "5,5" },
|
|
34
|
+
markerEnd: { type: "arrowclosed" as const, color: "#6366f1" },
|
|
35
|
+
animated: false,
|
|
36
|
+
},
|
|
37
|
+
tracks: {
|
|
38
|
+
style: { stroke: "#a855f7", strokeWidth: 2, strokeDasharray: "2,2" },
|
|
39
|
+
markerEnd: { type: "arrowclosed" as const, color: "#a855f7" },
|
|
40
|
+
animated: false,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function MultiLevelGraph({ data }: { data: MultiLevelGraphData }) {
|
|
45
|
+
const { initialNodes, initialEdges } = useMemo(() => {
|
|
46
|
+
const rfNodes: Node[] = data.nodes.map((n) => ({
|
|
47
|
+
id: n.id,
|
|
48
|
+
type: n.type,
|
|
49
|
+
position: n.position,
|
|
50
|
+
data: n.data,
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const rfEdges: Edge[] = data.edges.map((e) => {
|
|
54
|
+
const edgeStyle = EDGE_STYLES[e.type];
|
|
55
|
+
return {
|
|
56
|
+
id: e.id,
|
|
57
|
+
source: e.source,
|
|
58
|
+
target: e.target,
|
|
59
|
+
...edgeStyle,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { initialNodes: rfNodes, initialEdges: rfEdges };
|
|
64
|
+
}, [data]);
|
|
65
|
+
|
|
66
|
+
const [nodes, , onNodesChange] = useNodesState(initialNodes);
|
|
67
|
+
const [edges, , onEdgesChange] = useEdgesState(initialEdges);
|
|
68
|
+
|
|
69
|
+
const onInit = useCallback((instance: { fitView: () => void }) => {
|
|
70
|
+
instance.fitView();
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="h-[calc(100vh-10rem)] w-full rounded-lg border border-gray-200 bg-white">
|
|
75
|
+
<ReactFlow
|
|
76
|
+
nodes={nodes}
|
|
77
|
+
edges={edges}
|
|
78
|
+
onNodesChange={onNodesChange}
|
|
79
|
+
onEdgesChange={onEdgesChange}
|
|
80
|
+
onInit={onInit}
|
|
81
|
+
nodeTypes={nodeTypes}
|
|
82
|
+
fitView
|
|
83
|
+
proOptions={{ hideAttribution: true }}
|
|
84
|
+
>
|
|
85
|
+
<Background />
|
|
86
|
+
<Controls />
|
|
87
|
+
<MiniMap nodeStrokeWidth={3} zoomable pannable />
|
|
88
|
+
</ReactFlow>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { memo, useCallback } from "react";
|
|
4
|
+
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
|
|
7
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
8
|
+
draft: "border-gray-300 bg-gray-50",
|
|
9
|
+
approved: "border-green-300 bg-green-50",
|
|
10
|
+
implemented: "border-blue-300 bg-blue-50",
|
|
11
|
+
deprecated: "border-red-300 bg-red-50",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type RequirementNodeData = {
|
|
15
|
+
label: string;
|
|
16
|
+
status: string;
|
|
17
|
+
priority: string;
|
|
18
|
+
specCount?: number;
|
|
19
|
+
onDrillDown?: (reqId: string) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function RequirementNodeComponent({ data, id }: NodeProps) {
|
|
23
|
+
const nodeData = data as RequirementNodeData;
|
|
24
|
+
const borderClass = STATUS_COLORS[nodeData.status] ?? "border-gray-300 bg-white";
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
|
|
27
|
+
const handleBodyClick = useCallback(() => {
|
|
28
|
+
router.push(`/requirements/${id}`);
|
|
29
|
+
}, [router, id]);
|
|
30
|
+
|
|
31
|
+
const handleDrillDown = useCallback(
|
|
32
|
+
(e: React.MouseEvent) => {
|
|
33
|
+
e.stopPropagation();
|
|
34
|
+
nodeData.onDrillDown?.(id);
|
|
35
|
+
},
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
[nodeData.onDrillDown, id],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const handleButtonMouseDown = useCallback((e: React.MouseEvent) => {
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className={`cursor-pointer rounded-lg border-2 px-3 py-2 shadow-sm ${borderClass}`}
|
|
47
|
+
style={{ width: 220 }}
|
|
48
|
+
onClick={handleBodyClick}
|
|
49
|
+
>
|
|
50
|
+
<Handle type="target" position={Position.Left} className="!bg-gray-400" />
|
|
51
|
+
<p className="truncate text-xs font-mono text-gray-500">{id}</p>
|
|
52
|
+
<p className="mt-0.5 truncate text-sm font-medium text-gray-900">
|
|
53
|
+
{nodeData.label}
|
|
54
|
+
</p>
|
|
55
|
+
{nodeData.onDrillDown && nodeData.specCount && nodeData.specCount > 0 ? (
|
|
56
|
+
<button
|
|
57
|
+
className="nodrag mt-1 text-xs text-gray-400"
|
|
58
|
+
onClick={handleDrillDown}
|
|
59
|
+
onMouseDown={handleButtonMouseDown}
|
|
60
|
+
>
|
|
61
|
+
📄 {nodeData.specCount} spec{nodeData.specCount > 1 ? "s" : ""}
|
|
62
|
+
</button>
|
|
63
|
+
) : null}
|
|
64
|
+
<Handle type="source" position={Position.Right} className="!bg-gray-400" />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const RequirementNode = memo(RequirementNodeComponent);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { memo } from "react";
|
|
4
|
+
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
|
5
|
+
|
|
6
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
7
|
+
draft: "bg-blue-200",
|
|
8
|
+
approved: "bg-green-200",
|
|
9
|
+
implemented: "bg-emerald-300",
|
|
10
|
+
deprecated: "bg-red-200",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type SpecificationNodeData = {
|
|
14
|
+
label: string;
|
|
15
|
+
status: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function SpecificationNodeComponent({ data, id }: NodeProps) {
|
|
19
|
+
const nodeData = data as SpecificationNodeData;
|
|
20
|
+
const bgClass = STATUS_COLORS[nodeData.status] ?? "bg-gray-200";
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className={`rounded-lg border shadow-sm px-3 py-2 ${bgClass}`}
|
|
25
|
+
style={{ width: 200 }}
|
|
26
|
+
>
|
|
27
|
+
<Handle type="target" position={Position.Left} className="!bg-gray-400" />
|
|
28
|
+
<p className="truncate text-xs font-mono text-gray-900">{id}</p>
|
|
29
|
+
<div className="mt-1">
|
|
30
|
+
<span className="inline-block rounded-full bg-white px-2 py-0.5 text-xs text-gray-700">
|
|
31
|
+
{nodeData.status}
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
<Handle type="source" position={Position.Right} className="!bg-gray-400" />
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SpecificationNode = memo(SpecificationNodeComponent);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from "react";
|
|
4
|
+
import { deleteRequirement } from "@/lib/actions";
|
|
5
|
+
|
|
6
|
+
export function DeleteButton({ id }: { id: string }) {
|
|
7
|
+
const [showConfirm, setShowConfirm] = useState(false);
|
|
8
|
+
const [isPending, startTransition] = useTransition();
|
|
9
|
+
|
|
10
|
+
function handleDelete() {
|
|
11
|
+
startTransition(async () => {
|
|
12
|
+
await deleteRequirement(id);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (showConfirm) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex items-center gap-2">
|
|
19
|
+
<span className="text-sm text-red-600">Delete {id}?</span>
|
|
20
|
+
<button
|
|
21
|
+
onClick={handleDelete}
|
|
22
|
+
disabled={isPending}
|
|
23
|
+
className="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
|
24
|
+
>
|
|
25
|
+
{isPending ? "Deleting..." : "Confirm"}
|
|
26
|
+
</button>
|
|
27
|
+
<button
|
|
28
|
+
onClick={() => setShowConfirm(false)}
|
|
29
|
+
disabled={isPending}
|
|
30
|
+
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
31
|
+
>
|
|
32
|
+
Cancel
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<button
|
|
40
|
+
onClick={() => setShowConfirm(true)}
|
|
41
|
+
className="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50"
|
|
42
|
+
>
|
|
43
|
+
Delete
|
|
44
|
+
</button>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Requirement } from "@reqord/shared";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
label: string;
|
|
8
|
+
selected: string[];
|
|
9
|
+
onChange: (ids: string[]) => void;
|
|
10
|
+
allRequirements: Requirement[];
|
|
11
|
+
excludeId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DependencyEditor({
|
|
15
|
+
label,
|
|
16
|
+
selected,
|
|
17
|
+
onChange,
|
|
18
|
+
allRequirements,
|
|
19
|
+
excludeId,
|
|
20
|
+
}: Props) {
|
|
21
|
+
const options = useMemo(
|
|
22
|
+
() => allRequirements.filter((r) => r.id !== excludeId),
|
|
23
|
+
[allRequirements, excludeId],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function handleToggle(id: string) {
|
|
27
|
+
if (selected.includes(id)) {
|
|
28
|
+
onChange(selected.filter((s) => s !== id));
|
|
29
|
+
} else {
|
|
30
|
+
onChange([...selected, id]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-2">
|
|
36
|
+
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
|
37
|
+
<div className="max-h-40 overflow-y-auto rounded-md border border-gray-300 p-2">
|
|
38
|
+
{options.length === 0 ? (
|
|
39
|
+
<p className="text-sm text-gray-400">No other requirements</p>
|
|
40
|
+
) : (
|
|
41
|
+
options.map((req) => (
|
|
42
|
+
<label
|
|
43
|
+
key={req.id}
|
|
44
|
+
className="flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50"
|
|
45
|
+
>
|
|
46
|
+
<input
|
|
47
|
+
type="checkbox"
|
|
48
|
+
checked={selected.includes(req.id)}
|
|
49
|
+
onChange={() => handleToggle(req.id)}
|
|
50
|
+
className="rounded border-gray-300"
|
|
51
|
+
/>
|
|
52
|
+
<span className="font-mono text-gray-500">{req.id}</span>
|
|
53
|
+
<span className="truncate">{req.title}</span>
|
|
54
|
+
</label>
|
|
55
|
+
))
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
{selected.length > 0 ? (
|
|
59
|
+
<div className="flex flex-wrap gap-1">
|
|
60
|
+
{selected.map((id) => (
|
|
61
|
+
<span
|
|
62
|
+
key={id}
|
|
63
|
+
className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-mono text-blue-700"
|
|
64
|
+
>
|
|
65
|
+
{id}
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={() => onChange(selected.filter((s) => s !== id))}
|
|
69
|
+
className="hover:text-blue-900"
|
|
70
|
+
>
|
|
71
|
+
x
|
|
72
|
+
</button>
|
|
73
|
+
</span>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
) : null}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { MarkdownRenderer } from "./markdown-renderer";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
value: string;
|
|
8
|
+
onChange: (value: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function MarkdownEditor({ value, onChange }: Props) {
|
|
12
|
+
const [showPreview, setShowPreview] = useState(false);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="space-y-2">
|
|
16
|
+
<div className="flex items-center justify-between">
|
|
17
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
18
|
+
Description (Markdown)
|
|
19
|
+
</label>
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
onClick={() => setShowPreview((prev) => !prev)}
|
|
23
|
+
className="text-sm text-blue-600 hover:text-blue-800"
|
|
24
|
+
>
|
|
25
|
+
{showPreview ? "Edit" : "Preview"}
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
{showPreview ? (
|
|
29
|
+
<div className="min-h-[200px] rounded-md border border-gray-300 bg-white p-4">
|
|
30
|
+
{value.trim() ? (
|
|
31
|
+
<MarkdownRenderer content={value} />
|
|
32
|
+
) : (
|
|
33
|
+
<p className="text-sm text-gray-400">No content</p>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
) : (
|
|
37
|
+
<textarea
|
|
38
|
+
value={value}
|
|
39
|
+
onChange={(e) => onChange(e.target.value)}
|
|
40
|
+
rows={10}
|
|
41
|
+
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
42
|
+
placeholder="Enter requirement description in Markdown..."
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import ReactMarkdown from "react-markdown";
|
|
4
|
+
import remarkGfm from "remark-gfm";
|
|
5
|
+
|
|
6
|
+
export function MarkdownRenderer({ content }: { content: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="prose prose-sm max-w-none">
|
|
9
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|