@echothink-ui/developer 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/README.md +5 -0
- package/dist/components/APIExplorer.d.ts +2 -0
- package/dist/components/BranchSelector.d.ts +2 -0
- package/dist/components/CodeBlock.d.ts +2 -0
- package/dist/components/CodeEditor.d.ts +2 -0
- package/dist/components/CommitList.d.ts +2 -0
- package/dist/components/DiffTable.d.ts +2 -0
- package/dist/components/DiffViewer.d.ts +2 -0
- package/dist/components/EventPayloadViewer.d.ts +2 -0
- package/dist/components/GitRepositoryPanel.d.ts +2 -0
- package/dist/components/JSONViewer.d.ts +2 -0
- package/dist/components/LogConsole.d.ts +2 -0
- package/dist/components/PullRequestPanel.d.ts +2 -0
- package/dist/components/RequestResponseViewer.d.ts +2 -0
- package/dist/components/SchemaViewer.d.ts +2 -0
- package/dist/components/TerminalPanel.d.ts +2 -0
- package/dist/components/TraceTimeline.d.ts +2 -0
- package/dist/components/WebhookEventViewer.d.ts +2 -0
- package/dist/components/YAMLViewer.d.ts +2 -0
- package/dist/components/devUtils.d.ts +10 -0
- package/dist/components/types.d.ts +196 -0
- package/dist/index.cjs +2627 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3651 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +2572 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/components/APIExplorer.tsx +205 -0
- package/src/components/BranchSelector.tsx +54 -0
- package/src/components/CodeBlock.tsx +127 -0
- package/src/components/CodeEditor.tsx +95 -0
- package/src/components/CommitList.tsx +100 -0
- package/src/components/DiffTable.tsx +288 -0
- package/src/components/DiffViewer.tsx +145 -0
- package/src/components/EventPayloadViewer.tsx +91 -0
- package/src/components/GitRepositoryPanel.tsx +73 -0
- package/src/components/JSONViewer.tsx +189 -0
- package/src/components/LogConsole.tsx +160 -0
- package/src/components/PullRequestPanel.test.tsx +52 -0
- package/src/components/PullRequestPanel.tsx +215 -0
- package/src/components/RequestResponseViewer.test.tsx +45 -0
- package/src/components/RequestResponseViewer.tsx +169 -0
- package/src/components/SchemaViewer.tsx +157 -0
- package/src/components/TerminalPanel.test.tsx +33 -0
- package/src/components/TerminalPanel.tsx +134 -0
- package/src/components/TraceTimeline.test.tsx +63 -0
- package/src/components/TraceTimeline.tsx +207 -0
- package/src/components/WebhookEventViewer.test.tsx +57 -0
- package/src/components/WebhookEventViewer.tsx +184 -0
- package/src/components/YAMLViewer.tsx +207 -0
- package/src/components/devUtils.ts +81 -0
- package/src/components/types.ts +230 -0
- package/src/index.tsx +72 -0
- package/src/styles.css +4296 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { JSONViewerProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function JSONViewer({
|
|
5
|
+
value,
|
|
6
|
+
defaultExpandedDepth = 2,
|
|
7
|
+
className,
|
|
8
|
+
...props
|
|
9
|
+
}: JSONViewerProps) {
|
|
10
|
+
const [view, setView] = React.useState<"tree" | "raw">("tree");
|
|
11
|
+
const treeTabRef = React.useRef<HTMLButtonElement>(null);
|
|
12
|
+
const rawTabRef = React.useRef<HTMLButtonElement>(null);
|
|
13
|
+
const treeTabId = React.useId();
|
|
14
|
+
const rawTabId = React.useId();
|
|
15
|
+
const treePanelId = React.useId();
|
|
16
|
+
const rawPanelId = React.useId();
|
|
17
|
+
const selectView = (nextView: "tree" | "raw", shouldFocus = false) => {
|
|
18
|
+
setView(nextView);
|
|
19
|
+
|
|
20
|
+
if (shouldFocus) {
|
|
21
|
+
window.requestAnimationFrame(() => {
|
|
22
|
+
(nextView === "tree" ? treeTabRef : rawTabRef).current?.focus();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
27
|
+
if (event.key === "ArrowLeft" || event.key === "Home") {
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
selectView("tree", true);
|
|
30
|
+
} else if (event.key === "ArrowRight" || event.key === "End") {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
selectView("raw", true);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<section
|
|
38
|
+
{...props}
|
|
39
|
+
className={`eth-dev-json-viewer ${className ?? ""}`}
|
|
40
|
+
data-eth-component="JSONViewer"
|
|
41
|
+
>
|
|
42
|
+
<header className="eth-dev-json-viewer__tabs" role="tablist" aria-label="JSON view mode">
|
|
43
|
+
<button
|
|
44
|
+
aria-controls={treePanelId}
|
|
45
|
+
aria-selected={view === "tree"}
|
|
46
|
+
className="eth-dev-json-viewer__tab"
|
|
47
|
+
id={treeTabId}
|
|
48
|
+
ref={treeTabRef}
|
|
49
|
+
role="tab"
|
|
50
|
+
tabIndex={view === "tree" ? 0 : -1}
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => selectView("tree")}
|
|
53
|
+
onKeyDown={onTabKeyDown}
|
|
54
|
+
>
|
|
55
|
+
Tree
|
|
56
|
+
</button>
|
|
57
|
+
<button
|
|
58
|
+
aria-controls={rawPanelId}
|
|
59
|
+
aria-selected={view === "raw"}
|
|
60
|
+
className="eth-dev-json-viewer__tab"
|
|
61
|
+
id={rawTabId}
|
|
62
|
+
ref={rawTabRef}
|
|
63
|
+
role="tab"
|
|
64
|
+
tabIndex={view === "raw" ? 0 : -1}
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => selectView("raw")}
|
|
67
|
+
onKeyDown={onTabKeyDown}
|
|
68
|
+
>
|
|
69
|
+
Raw
|
|
70
|
+
</button>
|
|
71
|
+
</header>
|
|
72
|
+
<div
|
|
73
|
+
aria-labelledby={treeTabId}
|
|
74
|
+
className="eth-dev-json-viewer__tree"
|
|
75
|
+
hidden={view !== "tree"}
|
|
76
|
+
id={treePanelId}
|
|
77
|
+
role="tabpanel"
|
|
78
|
+
tabIndex={0}
|
|
79
|
+
>
|
|
80
|
+
<div className="eth-dev-json-viewer__tree-body">
|
|
81
|
+
<JSONNode name="root" value={value} depth={0} expandedDepth={defaultExpandedDepth} />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<pre
|
|
85
|
+
aria-labelledby={rawTabId}
|
|
86
|
+
className="eth-dev-json-viewer__raw"
|
|
87
|
+
hidden={view !== "raw"}
|
|
88
|
+
id={rawPanelId}
|
|
89
|
+
role="tabpanel"
|
|
90
|
+
tabIndex={0}
|
|
91
|
+
>
|
|
92
|
+
<code>{formatRawValue(value)}</code>
|
|
93
|
+
</pre>
|
|
94
|
+
</section>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function JSONNode({
|
|
99
|
+
name,
|
|
100
|
+
value,
|
|
101
|
+
depth,
|
|
102
|
+
expandedDepth
|
|
103
|
+
}: {
|
|
104
|
+
name: string;
|
|
105
|
+
value: unknown;
|
|
106
|
+
depth: number;
|
|
107
|
+
expandedDepth: number;
|
|
108
|
+
}) {
|
|
109
|
+
if (value === null || typeof value !== "object") {
|
|
110
|
+
const type = leafType(value);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="eth-dev-json-viewer__leaf">
|
|
114
|
+
<span className="eth-dev-json-viewer__key">{name}</span>
|
|
115
|
+
<span className="eth-dev-json-viewer__punctuation">:</span>
|
|
116
|
+
<span className={`eth-dev-json-viewer__value eth-dev-json-viewer__value--${type}`}>
|
|
117
|
+
{formatLeafValue(value)}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const entries = Array.isArray(value)
|
|
124
|
+
? value.map((item, index) => [String(index), item] as const)
|
|
125
|
+
: Object.entries(value as Record<string, unknown>);
|
|
126
|
+
const isArray = Array.isArray(value);
|
|
127
|
+
const collectionMeta = isArray ? `[${entries.length}]` : `{${entries.length}}`;
|
|
128
|
+
|
|
129
|
+
if (entries.length === 0) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="eth-dev-json-viewer__leaf eth-dev-json-viewer__leaf--empty">
|
|
132
|
+
<span className="eth-dev-json-viewer__key">{name}</span>
|
|
133
|
+
<span className="eth-dev-json-viewer__node-meta">{isArray ? "[]" : "{}"}</span>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<details open={depth < expandedDepth} className="eth-dev-json-viewer__branch">
|
|
140
|
+
<summary>
|
|
141
|
+
<span className="eth-dev-json-viewer__disclosure" aria-hidden="true" />
|
|
142
|
+
<span className="eth-dev-json-viewer__summary-label">{name}</span>
|
|
143
|
+
<span className="eth-dev-json-viewer__node-meta">{collectionMeta}</span>
|
|
144
|
+
</summary>
|
|
145
|
+
<div className="eth-dev-json-viewer__children">
|
|
146
|
+
{entries.map(([key, child]) => (
|
|
147
|
+
<JSONNode
|
|
148
|
+
key={key}
|
|
149
|
+
name={key}
|
|
150
|
+
value={child}
|
|
151
|
+
depth={depth + 1}
|
|
152
|
+
expandedDepth={expandedDepth}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</details>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function leafType(value: unknown) {
|
|
161
|
+
if (value === null) return "null";
|
|
162
|
+
return typeof value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatRawValue(value: unknown) {
|
|
166
|
+
try {
|
|
167
|
+
const formatted = JSON.stringify(value, null, 2);
|
|
168
|
+
|
|
169
|
+
if (typeof formatted === "string") return formatted;
|
|
170
|
+
} catch {
|
|
171
|
+
// Fall back to the same scalar formatter used by the tree view.
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return formatLeafValue(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatLeafValue(value: unknown) {
|
|
178
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
179
|
+
if (typeof value === "undefined") return "undefined";
|
|
180
|
+
if (typeof value === "symbol") return value.toString();
|
|
181
|
+
if (typeof value === "function") return "[Function]";
|
|
182
|
+
if (value === null) return "null";
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
return JSON.stringify(value);
|
|
186
|
+
} catch {
|
|
187
|
+
return String(value);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Pause, Play } from "@carbon/icons-react";
|
|
3
|
+
import { Badge, Button } from "@echothink-ui/core";
|
|
4
|
+
import type { LogConsoleProps, LogEntry } from "./types";
|
|
5
|
+
|
|
6
|
+
type LogEntryTone = "debug" | "info" | "warn" | "error" | "stdout" | "stderr";
|
|
7
|
+
|
|
8
|
+
const levelAliases: Record<string, LogEntryTone> = {
|
|
9
|
+
debug: "debug",
|
|
10
|
+
error: "error",
|
|
11
|
+
err: "error",
|
|
12
|
+
fatal: "error",
|
|
13
|
+
info: "info",
|
|
14
|
+
notice: "info",
|
|
15
|
+
stderr: "stderr",
|
|
16
|
+
stdout: "stdout",
|
|
17
|
+
trace: "debug",
|
|
18
|
+
warn: "warn",
|
|
19
|
+
warning: "warn"
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function getEntryLevel(entry: LogEntry) {
|
|
23
|
+
return (entry.level ?? entry.stream ?? "info").toString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getEntryTone(entry: LogEntry) {
|
|
27
|
+
return levelAliases[getEntryLevel(entry).toLowerCase()] ?? "info";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isWarning(entry: LogEntry) {
|
|
31
|
+
return getEntryTone(entry) === "warn";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isError(entry: LogEntry) {
|
|
35
|
+
const tone = getEntryTone(entry);
|
|
36
|
+
return tone === "error" || tone === "stderr";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatLevel(entry: LogEntry) {
|
|
40
|
+
return getEntryLevel(entry).toUpperCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatMessage(entry: LogEntry) {
|
|
44
|
+
return entry.message ?? entry.text ?? "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function LogConsole({
|
|
48
|
+
entries,
|
|
49
|
+
onPause,
|
|
50
|
+
streaming = false,
|
|
51
|
+
filters,
|
|
52
|
+
className,
|
|
53
|
+
role = "region",
|
|
54
|
+
"aria-label": ariaLabel = "Streaming log console",
|
|
55
|
+
"aria-labelledby": ariaLabelledBy,
|
|
56
|
+
...props
|
|
57
|
+
}: LogConsoleProps) {
|
|
58
|
+
const warningCount = entries.filter(isWarning).length;
|
|
59
|
+
const errorCount = entries.filter(isError).length;
|
|
60
|
+
const latestEntry = entries[entries.length - 1];
|
|
61
|
+
const latestTimestamp = latestEntry?.timestamp ?? "No activity";
|
|
62
|
+
const hasToolbar = Boolean(filters || onPause);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<section
|
|
66
|
+
{...props}
|
|
67
|
+
role={role}
|
|
68
|
+
aria-label={ariaLabelledBy ? undefined : ariaLabel}
|
|
69
|
+
aria-labelledby={ariaLabelledBy}
|
|
70
|
+
className={`eth-dev-log-console ${streaming ? "eth-dev-log-console--streaming" : "eth-dev-log-console--paused"} ${className ?? ""}`}
|
|
71
|
+
data-eth-component="LogConsole"
|
|
72
|
+
>
|
|
73
|
+
<header className="eth-dev-log-console__header">
|
|
74
|
+
<div className="eth-dev-log-console__heading">
|
|
75
|
+
<p className="eth-dev-log-console__eyebrow">Developer console</p>
|
|
76
|
+
<h3>Streaming logs</h3>
|
|
77
|
+
<p>
|
|
78
|
+
{entries.length} {entries.length === 1 ? "entry" : "entries"} buffered from the active
|
|
79
|
+
stream.
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="eth-dev-log-console__status" aria-live="polite">
|
|
83
|
+
<Badge severity={streaming ? "success" : "neutral"}>
|
|
84
|
+
{streaming ? "Streaming" : "Paused"}
|
|
85
|
+
</Badge>
|
|
86
|
+
<span>{errorCount ? `${errorCount} errors` : "No errors"}</span>
|
|
87
|
+
</div>
|
|
88
|
+
</header>
|
|
89
|
+
{hasToolbar ? (
|
|
90
|
+
<div className="eth-dev-log-console__toolbar" role="toolbar" aria-label="Log controls">
|
|
91
|
+
{filters ? <div className="eth-dev-log-console__filters">{filters}</div> : null}
|
|
92
|
+
{onPause ? (
|
|
93
|
+
<Button
|
|
94
|
+
intent={streaming ? "secondary" : "primary"}
|
|
95
|
+
density="compact"
|
|
96
|
+
icon={streaming ? <Pause size={16} /> : <Play size={16} />}
|
|
97
|
+
onClick={onPause}
|
|
98
|
+
>
|
|
99
|
+
{streaming ? "Pause stream" : "Resume stream"}
|
|
100
|
+
</Button>
|
|
101
|
+
) : null}
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
<dl className="eth-dev-log-console__summary" aria-label="Log summary">
|
|
105
|
+
<div>
|
|
106
|
+
<dt>Entries</dt>
|
|
107
|
+
<dd>{entries.length}</dd>
|
|
108
|
+
</div>
|
|
109
|
+
<div>
|
|
110
|
+
<dt>Warnings</dt>
|
|
111
|
+
<dd>{warningCount}</dd>
|
|
112
|
+
</div>
|
|
113
|
+
<div>
|
|
114
|
+
<dt>Errors</dt>
|
|
115
|
+
<dd>{errorCount}</dd>
|
|
116
|
+
</div>
|
|
117
|
+
<div>
|
|
118
|
+
<dt>Latest</dt>
|
|
119
|
+
<dd>{latestTimestamp}</dd>
|
|
120
|
+
</div>
|
|
121
|
+
</dl>
|
|
122
|
+
<div className="eth-dev-log-console__viewport">
|
|
123
|
+
{entries.length ? (
|
|
124
|
+
<div
|
|
125
|
+
className="eth-dev-log-console__entries"
|
|
126
|
+
role="log"
|
|
127
|
+
aria-live={streaming ? "polite" : "off"}
|
|
128
|
+
aria-relevant="additions text"
|
|
129
|
+
aria-atomic="false"
|
|
130
|
+
>
|
|
131
|
+
{entries.map((entry) => {
|
|
132
|
+
const tone = getEntryTone(entry);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
key={entry.id}
|
|
137
|
+
className={`eth-dev-log-console__entry eth-dev-log-console__entry--${tone}`}
|
|
138
|
+
>
|
|
139
|
+
<time className="eth-dev-log-console__timestamp">
|
|
140
|
+
{entry.timestamp ?? "--:--:--"}
|
|
141
|
+
</time>
|
|
142
|
+
<span className="eth-dev-log-console__level">{formatLevel(entry)}</span>
|
|
143
|
+
<span className="eth-dev-log-console__source">
|
|
144
|
+
{entry.source ?? entry.stream ?? "system"}
|
|
145
|
+
</span>
|
|
146
|
+
<code className="eth-dev-log-console__message">{formatMessage(entry)}</code>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
) : (
|
|
152
|
+
<div className="eth-dev-log-console__empty" role="status">
|
|
153
|
+
<h4>No log entries</h4>
|
|
154
|
+
<p>Events will appear here when the stream receives output.</p>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</section>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { PullRequestPanel } from "./PullRequestPanel";
|
|
4
|
+
|
|
5
|
+
describe("PullRequestPanel", () => {
|
|
6
|
+
it("renders pull request summary, file deltas, and review actions", () => {
|
|
7
|
+
const approve = vi.fn();
|
|
8
|
+
const comment = vi.fn();
|
|
9
|
+
|
|
10
|
+
render(
|
|
11
|
+
<PullRequestPanel
|
|
12
|
+
pr={{
|
|
13
|
+
id: "42",
|
|
14
|
+
title: "Add approval gate v2",
|
|
15
|
+
body: "Adds a richer approval gate with evidence and policy refs.",
|
|
16
|
+
state: "open",
|
|
17
|
+
author: "JD",
|
|
18
|
+
reviewers: ["MK", "AL"],
|
|
19
|
+
commits: 6,
|
|
20
|
+
files: [
|
|
21
|
+
{ path: "src/api.ts", additions: 12, deletions: 3 },
|
|
22
|
+
{ path: "src/ui.tsx", additions: 4, deletions: 8 }
|
|
23
|
+
]
|
|
24
|
+
}}
|
|
25
|
+
onApprove={approve}
|
|
26
|
+
onComment={comment}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const panel = screen.getByRole("region", {
|
|
31
|
+
name: "Pull request Add approval gate v2"
|
|
32
|
+
});
|
|
33
|
+
expect(within(panel).getByText("Open")).toBeTruthy();
|
|
34
|
+
expect(within(panel).getByText("#42")).toBeTruthy();
|
|
35
|
+
expect(within(panel).getByText("MK, AL")).toBeTruthy();
|
|
36
|
+
|
|
37
|
+
const filesTable = within(panel).getByRole("table", { name: "Changed files" });
|
|
38
|
+
expect(within(filesTable).getByText("src/api.ts")).toBeTruthy();
|
|
39
|
+
expect(within(filesTable).getByText("+12")).toBeTruthy();
|
|
40
|
+
expect(within(filesTable).getByText("-3")).toBeTruthy();
|
|
41
|
+
|
|
42
|
+
fireEvent.click(within(panel).getByRole("button", { name: "Approve pull request" }));
|
|
43
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
44
|
+
|
|
45
|
+
fireEvent.change(within(panel).getByRole("textbox", { name: "Review comment" }), {
|
|
46
|
+
target: { value: "Looks good." }
|
|
47
|
+
});
|
|
48
|
+
fireEvent.click(within(panel).getByRole("button", { name: "Submit review comment" }));
|
|
49
|
+
|
|
50
|
+
expect(comment).toHaveBeenCalledWith("Looks good.");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Checkmark, PullRequest } from "@carbon/icons-react";
|
|
3
|
+
import { Button, EmptyState, Textarea } from "@echothink-ui/core";
|
|
4
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
5
|
+
import type { PullRequestPanelProps } from "./types";
|
|
6
|
+
|
|
7
|
+
type FileRow = { path: string; additions: number; deletions: number } & Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
function formatState(state: string) {
|
|
10
|
+
const normalized = state.trim();
|
|
11
|
+
if (!normalized) return "Unknown";
|
|
12
|
+
return normalized
|
|
13
|
+
.split(/[\s_-]+/)
|
|
14
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`)
|
|
15
|
+
.join(" ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stateClassName(state: string) {
|
|
19
|
+
const normalized = state.trim().toLowerCase();
|
|
20
|
+
if (["open", "opened", "ready"].includes(normalized)) {
|
|
21
|
+
return "eth-dev-pull-request-panel__state--open";
|
|
22
|
+
}
|
|
23
|
+
if (["merged", "approved"].includes(normalized)) {
|
|
24
|
+
return "eth-dev-pull-request-panel__state--merged";
|
|
25
|
+
}
|
|
26
|
+
if (["closed", "rejected", "blocked"].includes(normalized)) {
|
|
27
|
+
return "eth-dev-pull-request-panel__state--closed";
|
|
28
|
+
}
|
|
29
|
+
if (["draft", "pending"].includes(normalized)) {
|
|
30
|
+
return "eth-dev-pull-request-panel__state--draft";
|
|
31
|
+
}
|
|
32
|
+
return "eth-dev-pull-request-panel__state--unknown";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function PullRequestPanel({
|
|
36
|
+
pr,
|
|
37
|
+
onApprove,
|
|
38
|
+
onComment,
|
|
39
|
+
className,
|
|
40
|
+
...props
|
|
41
|
+
}: PullRequestPanelProps) {
|
|
42
|
+
const [comment, setComment] = React.useState("");
|
|
43
|
+
const titleId = React.useId().replace(/:/g, "");
|
|
44
|
+
const filesTitleId = React.useId().replace(/:/g, "");
|
|
45
|
+
const files = pr.files ?? [];
|
|
46
|
+
const reviewers = pr.reviewers?.length ? pr.reviewers.join(", ") : "No reviewers";
|
|
47
|
+
const additions = files.reduce((sum, file) => sum + file.additions, 0);
|
|
48
|
+
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
|
49
|
+
const formattedState = formatState(pr.state);
|
|
50
|
+
const ariaLabel =
|
|
51
|
+
props["aria-label"] ?? (props["aria-labelledby"] ? undefined : `Pull request ${pr.title}`);
|
|
52
|
+
|
|
53
|
+
const columns: DataColumn<FileRow>[] = [
|
|
54
|
+
{
|
|
55
|
+
key: "path",
|
|
56
|
+
header: "File",
|
|
57
|
+
render: (row) => (
|
|
58
|
+
<code className="eth-dev-pull-request-panel__file-path" title={row.path}>
|
|
59
|
+
{row.path}
|
|
60
|
+
</code>
|
|
61
|
+
)
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: "additions",
|
|
65
|
+
header: "Additions",
|
|
66
|
+
align: "end",
|
|
67
|
+
width: "7rem",
|
|
68
|
+
render: (row) => (
|
|
69
|
+
<span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--added">
|
|
70
|
+
+{row.additions}
|
|
71
|
+
</span>
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: "deletions",
|
|
76
|
+
header: "Deletions",
|
|
77
|
+
align: "end",
|
|
78
|
+
width: "7rem",
|
|
79
|
+
render: (row) => (
|
|
80
|
+
<span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--removed">
|
|
81
|
+
-{row.deletions}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
const submitComment = (event: React.FormEvent<HTMLFormElement>) => {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
const nextComment = comment.trim();
|
|
89
|
+
if (!nextComment) return;
|
|
90
|
+
onComment?.(nextComment);
|
|
91
|
+
setComment("");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<section
|
|
96
|
+
{...props}
|
|
97
|
+
aria-label={ariaLabel}
|
|
98
|
+
className={`eth-dev-pull-request-panel ${className ?? ""}`}
|
|
99
|
+
data-eth-component="PullRequestPanel"
|
|
100
|
+
role="region"
|
|
101
|
+
>
|
|
102
|
+
<header className="eth-dev-pull-request-panel__header">
|
|
103
|
+
<div className="eth-dev-pull-request-panel__identity">
|
|
104
|
+
<span className="eth-dev-pull-request-panel__icon" aria-hidden="true">
|
|
105
|
+
<PullRequest size={20} />
|
|
106
|
+
</span>
|
|
107
|
+
<div className="eth-dev-pull-request-panel__heading">
|
|
108
|
+
<p>
|
|
109
|
+
Pull request <span>#{pr.id}</span>
|
|
110
|
+
</p>
|
|
111
|
+
<h3 id={titleId}>{pr.title}</h3>
|
|
112
|
+
<div className="eth-dev-pull-request-panel__meta">
|
|
113
|
+
<span
|
|
114
|
+
className={`eth-dev-pull-request-panel__state ${stateClassName(pr.state)}`}
|
|
115
|
+
>
|
|
116
|
+
{formattedState}
|
|
117
|
+
</span>
|
|
118
|
+
<span>by {pr.author}</span>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
{onApprove ? (
|
|
123
|
+
<Button
|
|
124
|
+
aria-label="Approve pull request"
|
|
125
|
+
className="eth-dev-pull-request-panel__approve"
|
|
126
|
+
density="compact"
|
|
127
|
+
icon={<Checkmark size={16} />}
|
|
128
|
+
intent="success"
|
|
129
|
+
onClick={onApprove}
|
|
130
|
+
>
|
|
131
|
+
Approve
|
|
132
|
+
</Button>
|
|
133
|
+
) : null}
|
|
134
|
+
</header>
|
|
135
|
+
<div className="eth-dev-pull-request-panel__body">
|
|
136
|
+
{pr.body ? <p className="eth-dev-pull-request-panel__description">{pr.body}</p> : null}
|
|
137
|
+
<dl className="eth-dev-pull-request-panel__metrics" aria-label="Pull request summary">
|
|
138
|
+
<div>
|
|
139
|
+
<dt>Reviewers</dt>
|
|
140
|
+
<dd title={reviewers}>{reviewers}</dd>
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<dt>Commits</dt>
|
|
144
|
+
<dd>{pr.commits ?? 0}</dd>
|
|
145
|
+
</div>
|
|
146
|
+
<div>
|
|
147
|
+
<dt>Files changed</dt>
|
|
148
|
+
<dd>{files.length}</dd>
|
|
149
|
+
</div>
|
|
150
|
+
<div>
|
|
151
|
+
<dt>Line delta</dt>
|
|
152
|
+
<dd>
|
|
153
|
+
<span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--added">
|
|
154
|
+
+{additions}
|
|
155
|
+
</span>
|
|
156
|
+
<span aria-hidden="true"> / </span>
|
|
157
|
+
<span className="eth-dev-pull-request-panel__count eth-dev-pull-request-panel__count--removed">
|
|
158
|
+
-{deletions}
|
|
159
|
+
</span>
|
|
160
|
+
</dd>
|
|
161
|
+
</div>
|
|
162
|
+
</dl>
|
|
163
|
+
</div>
|
|
164
|
+
<section className="eth-dev-pull-request-panel__files" aria-labelledby={filesTitleId}>
|
|
165
|
+
<div className="eth-dev-pull-request-panel__files-header">
|
|
166
|
+
<h4 id={filesTitleId}>Changed files</h4>
|
|
167
|
+
<span>{files.length} files</span>
|
|
168
|
+
</div>
|
|
169
|
+
{files.length ? (
|
|
170
|
+
<DataTable
|
|
171
|
+
aria-label="Changed files"
|
|
172
|
+
className="eth-dev-pull-request-panel__table"
|
|
173
|
+
columns={columns}
|
|
174
|
+
density="compact"
|
|
175
|
+
rowKey="path"
|
|
176
|
+
rows={files as FileRow[]}
|
|
177
|
+
/>
|
|
178
|
+
) : (
|
|
179
|
+
<EmptyState
|
|
180
|
+
title="No file changes"
|
|
181
|
+
description="Changed files will appear when this pull request has a diff."
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
</section>
|
|
185
|
+
{onComment ? (
|
|
186
|
+
<form
|
|
187
|
+
aria-label="Review comment form"
|
|
188
|
+
className="eth-dev-pull-request-panel__comment"
|
|
189
|
+
onSubmit={submitComment}
|
|
190
|
+
>
|
|
191
|
+
<Textarea
|
|
192
|
+
helperText="Add a blocking question, approval note, or evidence reference."
|
|
193
|
+
labelText="Review comment"
|
|
194
|
+
onChange={(event) => setComment(event.currentTarget.value)}
|
|
195
|
+
placeholder="Reference tests, policy checks, or follow-up questions."
|
|
196
|
+
rows={4}
|
|
197
|
+
value={comment}
|
|
198
|
+
/>
|
|
199
|
+
<div className="eth-dev-pull-request-panel__comment-footer">
|
|
200
|
+
<span>Comment will be attached to this pull request.</span>
|
|
201
|
+
<Button
|
|
202
|
+
aria-label="Submit review comment"
|
|
203
|
+
density="compact"
|
|
204
|
+
disabled={!comment.trim()}
|
|
205
|
+
intent="secondary"
|
|
206
|
+
type="submit"
|
|
207
|
+
>
|
|
208
|
+
Comment
|
|
209
|
+
</Button>
|
|
210
|
+
</div>
|
|
211
|
+
</form>
|
|
212
|
+
) : null}
|
|
213
|
+
</section>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { RequestResponseViewer } from "./RequestResponseViewer";
|
|
4
|
+
|
|
5
|
+
describe("RequestResponseViewer", () => {
|
|
6
|
+
it("renders HTTP metadata and accessible request/response tabs", () => {
|
|
7
|
+
render(
|
|
8
|
+
<RequestResponseViewer
|
|
9
|
+
request={{
|
|
10
|
+
method: "POST",
|
|
11
|
+
url: "/v1/tasks/q3/approve",
|
|
12
|
+
headers: { "content-type": "application/json", authorization: "Bearer ***" },
|
|
13
|
+
body: { reason: "approved by Jane" }
|
|
14
|
+
}}
|
|
15
|
+
response={{
|
|
16
|
+
status: 200,
|
|
17
|
+
headers: { "content-type": "application/json" },
|
|
18
|
+
body: { ok: true, taskId: "q3" },
|
|
19
|
+
durationMs: 184
|
|
20
|
+
}}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const region = screen.getByRole("region", {
|
|
25
|
+
name: "POST /v1/tasks/q3/approve 200"
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(within(region).getByText("POST")).toBeTruthy();
|
|
29
|
+
expect(within(region).getByText("/v1/tasks/q3/approve")).toBeTruthy();
|
|
30
|
+
expect(within(region).getByText("200")).toBeTruthy();
|
|
31
|
+
expect(within(region).getByText("184 ms")).toBeTruthy();
|
|
32
|
+
|
|
33
|
+
const tabs = within(region).getAllByRole("tab");
|
|
34
|
+
expect(tabs).toHaveLength(4);
|
|
35
|
+
expect(
|
|
36
|
+
within(region).getByRole("tab", { name: "Request headers" }).getAttribute("aria-selected")
|
|
37
|
+
).toBe("true");
|
|
38
|
+
|
|
39
|
+
fireEvent.click(within(region).getByRole("tab", { name: "Response body" }));
|
|
40
|
+
|
|
41
|
+
expect(within(region).getByRole("tabpanel", { name: "Response body" }).textContent).toContain(
|
|
42
|
+
'"taskId": "q3"'
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|