@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.
Files changed (56) hide show
  1. package/README.md +5 -0
  2. package/dist/components/APIExplorer.d.ts +2 -0
  3. package/dist/components/BranchSelector.d.ts +2 -0
  4. package/dist/components/CodeBlock.d.ts +2 -0
  5. package/dist/components/CodeEditor.d.ts +2 -0
  6. package/dist/components/CommitList.d.ts +2 -0
  7. package/dist/components/DiffTable.d.ts +2 -0
  8. package/dist/components/DiffViewer.d.ts +2 -0
  9. package/dist/components/EventPayloadViewer.d.ts +2 -0
  10. package/dist/components/GitRepositoryPanel.d.ts +2 -0
  11. package/dist/components/JSONViewer.d.ts +2 -0
  12. package/dist/components/LogConsole.d.ts +2 -0
  13. package/dist/components/PullRequestPanel.d.ts +2 -0
  14. package/dist/components/RequestResponseViewer.d.ts +2 -0
  15. package/dist/components/SchemaViewer.d.ts +2 -0
  16. package/dist/components/TerminalPanel.d.ts +2 -0
  17. package/dist/components/TraceTimeline.d.ts +2 -0
  18. package/dist/components/WebhookEventViewer.d.ts +2 -0
  19. package/dist/components/YAMLViewer.d.ts +2 -0
  20. package/dist/components/devUtils.d.ts +10 -0
  21. package/dist/components/types.d.ts +196 -0
  22. package/dist/index.cjs +2627 -0
  23. package/dist/index.cjs.map +1 -0
  24. package/dist/index.css +3651 -0
  25. package/dist/index.css.map +1 -0
  26. package/dist/index.d.ts +22 -0
  27. package/dist/index.js +2572 -0
  28. package/dist/index.js.map +1 -0
  29. package/package.json +43 -0
  30. package/src/components/APIExplorer.tsx +205 -0
  31. package/src/components/BranchSelector.tsx +54 -0
  32. package/src/components/CodeBlock.tsx +127 -0
  33. package/src/components/CodeEditor.tsx +95 -0
  34. package/src/components/CommitList.tsx +100 -0
  35. package/src/components/DiffTable.tsx +288 -0
  36. package/src/components/DiffViewer.tsx +145 -0
  37. package/src/components/EventPayloadViewer.tsx +91 -0
  38. package/src/components/GitRepositoryPanel.tsx +73 -0
  39. package/src/components/JSONViewer.tsx +189 -0
  40. package/src/components/LogConsole.tsx +160 -0
  41. package/src/components/PullRequestPanel.test.tsx +52 -0
  42. package/src/components/PullRequestPanel.tsx +215 -0
  43. package/src/components/RequestResponseViewer.test.tsx +45 -0
  44. package/src/components/RequestResponseViewer.tsx +169 -0
  45. package/src/components/SchemaViewer.tsx +157 -0
  46. package/src/components/TerminalPanel.test.tsx +33 -0
  47. package/src/components/TerminalPanel.tsx +134 -0
  48. package/src/components/TraceTimeline.test.tsx +63 -0
  49. package/src/components/TraceTimeline.tsx +207 -0
  50. package/src/components/WebhookEventViewer.test.tsx +57 -0
  51. package/src/components/WebhookEventViewer.tsx +184 -0
  52. package/src/components/YAMLViewer.tsx +207 -0
  53. package/src/components/devUtils.ts +81 -0
  54. package/src/components/types.ts +230 -0
  55. package/src/index.tsx +72 -0
  56. package/src/styles.css +4296 -0
@@ -0,0 +1,169 @@
1
+ import * as React from "react";
2
+ import type { RequestResponseViewerProps } from "./types";
3
+ import { formatUnknown } from "./devUtils";
4
+
5
+ type Tab = "request-headers" | "request-body" | "response-headers" | "response-body";
6
+
7
+ const tabs: Array<{ id: Tab; label: string; emptyLabel: string }> = [
8
+ { id: "request-headers", label: "Request headers", emptyLabel: "No request headers." },
9
+ { id: "request-body", label: "Request body", emptyLabel: "No request body." },
10
+ { id: "response-headers", label: "Response headers", emptyLabel: "No response headers." },
11
+ { id: "response-body", label: "Response body", emptyLabel: "No response body." }
12
+ ];
13
+
14
+ export function RequestResponseViewer({
15
+ request,
16
+ response,
17
+ className,
18
+ "aria-label": ariaLabel,
19
+ ...props
20
+ }: RequestResponseViewerProps) {
21
+ const [tab, setTab] = React.useState<Tab>("request-headers");
22
+ const tabRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
23
+ const generatedId = React.useId().replace(/:/g, "");
24
+ const activeIndex = Math.max(
25
+ tabs.findIndex((item) => item.id === tab),
26
+ 0
27
+ );
28
+ const regionLabel = `${request.method} ${request.url} ${response.status}`;
29
+ const durationLabel =
30
+ typeof response.durationMs === "number" ? `${response.durationMs} ms` : "Duration unavailable";
31
+
32
+ const selectTab = (nextIndex: number, shouldFocus = false) => {
33
+ const nextTab = tabs[nextIndex] ?? tabs[0]!;
34
+
35
+ setTab(nextTab.id);
36
+
37
+ if (shouldFocus) {
38
+ window.requestAnimationFrame(() => tabRefs.current[nextIndex]?.focus());
39
+ }
40
+ };
41
+
42
+ const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
43
+ if (event.key === "ArrowLeft") {
44
+ event.preventDefault();
45
+ selectTab((activeIndex - 1 + tabs.length) % tabs.length, true);
46
+ } else if (event.key === "ArrowRight") {
47
+ event.preventDefault();
48
+ selectTab((activeIndex + 1) % tabs.length, true);
49
+ } else if (event.key === "Home") {
50
+ event.preventDefault();
51
+ selectTab(0, true);
52
+ } else if (event.key === "End") {
53
+ event.preventDefault();
54
+ selectTab(tabs.length - 1, true);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <section
60
+ {...props}
61
+ aria-label={ariaLabel ?? regionLabel}
62
+ className={`eth-dev-request-response-viewer ${className ?? ""}`.trim()}
63
+ data-eth-component="RequestResponseViewer"
64
+ >
65
+ <header className="eth-dev-request-response-viewer__header">
66
+ <div className="eth-dev-request-response-viewer__request">
67
+ <span
68
+ className={`eth-dev-request-response-viewer__method eth-dev-request-response-viewer__method--${tokenize(request.method)}`}
69
+ >
70
+ {request.method}
71
+ </span>
72
+ <code title={request.url}>{request.url}</code>
73
+ </div>
74
+ <dl className="eth-dev-request-response-viewer__meta" aria-label="HTTP response summary">
75
+ <div>
76
+ <dt>Status</dt>
77
+ <dd
78
+ className={`eth-dev-request-response-viewer__status eth-dev-request-response-viewer__status--${statusTone(response.status)}`}
79
+ >
80
+ {response.status}
81
+ </dd>
82
+ </div>
83
+ <div>
84
+ <dt>Time</dt>
85
+ <dd>{durationLabel}</dd>
86
+ </div>
87
+ </dl>
88
+ </header>
89
+ <div
90
+ className="eth-dev-request-response-viewer__tabs"
91
+ role="tablist"
92
+ aria-label="HTTP message sections"
93
+ >
94
+ {tabs.map((item, index) => (
95
+ <button
96
+ key={item.id}
97
+ aria-controls={`${generatedId}-${item.id}-panel`}
98
+ aria-selected={tab === item.id}
99
+ className="eth-dev-request-response-viewer__tab"
100
+ id={`${generatedId}-${item.id}-tab`}
101
+ ref={(node) => {
102
+ tabRefs.current[index] = node;
103
+ }}
104
+ role="tab"
105
+ tabIndex={tab === item.id ? 0 : -1}
106
+ type="button"
107
+ onClick={() => selectTab(index)}
108
+ onKeyDown={onTabKeyDown}
109
+ >
110
+ {item.label}
111
+ </button>
112
+ ))}
113
+ </div>
114
+ {tabs.map((item) => {
115
+ const content = contentFor(item.id, request, response);
116
+
117
+ return (
118
+ <pre
119
+ key={item.id}
120
+ aria-labelledby={`${generatedId}-${item.id}-tab`}
121
+ className={`eth-dev-request-response-viewer__panel ${
122
+ content ? "" : "eth-dev-request-response-viewer__panel--empty"
123
+ }`.trim()}
124
+ hidden={tab !== item.id}
125
+ id={`${generatedId}-${item.id}-panel`}
126
+ role="tabpanel"
127
+ tabIndex={tab === item.id ? 0 : -1}
128
+ >
129
+ <code>{content || item.emptyLabel}</code>
130
+ </pre>
131
+ );
132
+ })}
133
+ </section>
134
+ );
135
+ }
136
+
137
+ function contentFor(
138
+ tab: Tab,
139
+ request: RequestResponseViewerProps["request"],
140
+ response: RequestResponseViewerProps["response"]
141
+ ) {
142
+ switch (tab) {
143
+ case "request-headers":
144
+ return formatHeaders(request.headers);
145
+ case "request-body":
146
+ return formatUnknown(request.body);
147
+ case "response-headers":
148
+ return formatHeaders(response.headers);
149
+ case "response-body":
150
+ return formatUnknown(response.body);
151
+ }
152
+ }
153
+
154
+ function formatHeaders(headers: Record<string, string> | undefined) {
155
+ if (!headers || Object.keys(headers).length === 0) return "";
156
+ return formatUnknown(headers);
157
+ }
158
+
159
+ function statusTone(status: number) {
160
+ if (status >= 200 && status < 300) return "success";
161
+ if (status >= 300 && status < 400) return "redirect";
162
+ if (status >= 400 && status < 500) return "warning";
163
+ if (status >= 500) return "error";
164
+ return "neutral";
165
+ }
166
+
167
+ function tokenize(value: string) {
168
+ return value.toLowerCase().replace(/[^a-z0-9-]/g, "");
169
+ }
@@ -0,0 +1,157 @@
1
+ import * as React from "react";
2
+ import { CheckmarkFilled, ErrorFilled, WarningFilled } from "@carbon/icons-react";
3
+ import type { SchemaViewerProps } from "./types";
4
+ import { JSONViewer } from "./JSONViewer";
5
+
6
+ type ValidationTone = "valid" | "invalid" | "warning" | "unknown";
7
+
8
+ interface SchemaStats {
9
+ nodes: number;
10
+ topLevelKeys: number;
11
+ properties: number;
12
+ required: number;
13
+ }
14
+
15
+ export function SchemaViewer({
16
+ schema,
17
+ validationState,
18
+ className,
19
+ ...props
20
+ }: SchemaViewerProps) {
21
+ const stats = React.useMemo(() => collectSchemaStats(schema), [schema]);
22
+ const tone = normalizeValidationState(validationState);
23
+ const status = getStatusConfig(tone, stats);
24
+
25
+ return (
26
+ <section
27
+ {...props}
28
+ className={`eth-dev-schema-viewer ${className ?? ""}`}
29
+ data-eth-component="SchemaViewer"
30
+ data-validation-state={tone}
31
+ >
32
+ <header className="eth-dev-schema-viewer__status">
33
+ <span className="eth-dev-schema-viewer__status-icon" aria-hidden="true">
34
+ <StatusIcon tone={tone} />
35
+ </span>
36
+ <div className="eth-dev-schema-viewer__status-copy">
37
+ <p className="eth-dev-schema-viewer__status-title">{status.title}</p>
38
+ <p className="eth-dev-schema-viewer__status-description">{status.description}</p>
39
+ </div>
40
+ <dl className="eth-dev-schema-viewer__stats" aria-label="Schema summary">
41
+ <div>
42
+ <dt>Keys</dt>
43
+ <dd>{stats.topLevelKeys}</dd>
44
+ </div>
45
+ <div>
46
+ <dt>Properties</dt>
47
+ <dd>{stats.properties}</dd>
48
+ </div>
49
+ <div>
50
+ <dt>Required</dt>
51
+ <dd>{stats.required}</dd>
52
+ </div>
53
+ </dl>
54
+ </header>
55
+ <JSONViewer className="eth-dev-schema-viewer__json" value={schema} defaultExpandedDepth={2} />
56
+ </section>
57
+ );
58
+ }
59
+
60
+ function normalizeValidationState(validationState: SchemaViewerProps["validationState"]): ValidationTone {
61
+ if (validationState === "valid" || validationState === "invalid" || validationState === "warning") {
62
+ return validationState;
63
+ }
64
+
65
+ return "unknown";
66
+ }
67
+
68
+ function getStatusConfig(tone: ValidationTone, stats: SchemaStats) {
69
+ const summary = `${stats.nodes} nodes inspected across ${stats.properties} properties`;
70
+
71
+ switch (tone) {
72
+ case "valid":
73
+ return {
74
+ title: "Schema valid",
75
+ description: summary
76
+ };
77
+ case "invalid":
78
+ return {
79
+ title: "Schema invalid",
80
+ description: "Review schema structure and validation rules before publishing."
81
+ };
82
+ case "warning":
83
+ return {
84
+ title: "Schema warning",
85
+ description: "Schema is readable, but validation reported conditions that need review."
86
+ };
87
+ default:
88
+ return {
89
+ title: "Schema status unknown",
90
+ description: summary
91
+ };
92
+ }
93
+ }
94
+
95
+ function collectSchemaStats(schema: object): SchemaStats {
96
+ const stats: SchemaStats = {
97
+ nodes: 0,
98
+ topLevelKeys: isPlainObject(schema)
99
+ ? Object.keys(schema).length
100
+ : Array.isArray(schema)
101
+ ? schema.length
102
+ : 1,
103
+ properties: 0,
104
+ required: 0
105
+ };
106
+ const seen = new WeakSet<object>();
107
+
108
+ const visit = (node: unknown, key?: string) => {
109
+ stats.nodes += 1;
110
+
111
+ if (Array.isArray(node)) {
112
+ if (seen.has(node)) {
113
+ return;
114
+ }
115
+
116
+ seen.add(node);
117
+
118
+ if (key === "required" && node.every((item) => typeof item === "string")) {
119
+ stats.required += node.length;
120
+ }
121
+
122
+ node.forEach((item) => visit(item));
123
+ return;
124
+ }
125
+
126
+ if (!isPlainObject(node) || seen.has(node)) {
127
+ return;
128
+ }
129
+
130
+ seen.add(node);
131
+
132
+ if (isPlainObject(node.properties)) {
133
+ stats.properties += Object.keys(node.properties).length;
134
+ }
135
+
136
+ Object.entries(node).forEach(([entryKey, entryValue]) => visit(entryValue, entryKey));
137
+ };
138
+
139
+ visit(schema);
140
+ return stats;
141
+ }
142
+
143
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
144
+ return typeof value === "object" && value !== null && !Array.isArray(value);
145
+ }
146
+
147
+ function StatusIcon({ tone }: { tone: ValidationTone }) {
148
+ if (tone === "valid") {
149
+ return <CheckmarkFilled size={20} />;
150
+ }
151
+
152
+ if (tone === "invalid") {
153
+ return <ErrorFilled size={20} />;
154
+ }
155
+
156
+ return <WarningFilled size={20} />;
157
+ }
@@ -0,0 +1,33 @@
1
+ import { fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { TerminalPanel } from "./TerminalPanel";
4
+
5
+ describe("TerminalPanel", () => {
6
+ it("renders a sandbox terminal region and submits typed commands", () => {
7
+ const onCommand = vi.fn();
8
+
9
+ render(
10
+ <TerminalPanel
11
+ lines={[
12
+ { id: "1", stream: "stdout", text: "$ ls -la" },
13
+ { id: "2", stream: "stdout", text: "total 24" },
14
+ { id: "3", stream: "stderr", text: "warning: untracked file 'tmp.txt'" }
15
+ ]}
16
+ onCommand={onCommand}
17
+ />
18
+ );
19
+
20
+ const panel = screen.getByRole("region", { name: "Sandbox terminal" });
21
+ expect(within(panel).getByRole("log", { name: "Terminal output" })).toBeTruthy();
22
+ expect(within(panel).getByText("Sandboxed")).toBeTruthy();
23
+ expect(within(panel).getByText("ERR")).toBeTruthy();
24
+ expect(within(panel).getByText("warning: untracked file 'tmp.txt'")).toBeTruthy();
25
+
26
+ const input = within(panel).getByRole("textbox", { name: "Terminal command" });
27
+ fireEvent.change(input, { target: { value: "pwd" } });
28
+ fireEvent.click(within(panel).getByRole("button", { name: "Run command" }));
29
+
30
+ expect(onCommand).toHaveBeenCalledWith("pwd");
31
+ expect((input as HTMLInputElement).value).toBe("");
32
+ });
33
+ });
@@ -0,0 +1,134 @@
1
+ import * as React from "react";
2
+ import { Play } from "@carbon/icons-react";
3
+ import { Button } from "@echothink-ui/core";
4
+ import type { TerminalPanelProps } from "./types";
5
+
6
+ export function TerminalPanel({
7
+ lines,
8
+ prompt = "$",
9
+ onCommand,
10
+ disabled,
11
+ className,
12
+ role = "region",
13
+ "aria-label": ariaLabel = "Sandbox terminal",
14
+ "aria-labelledby": ariaLabelledBy,
15
+ ...props
16
+ }: TerminalPanelProps) {
17
+ const [command, setCommand] = React.useState("");
18
+ const inputId = React.useId();
19
+ const errorCount = lines.filter((line) => line.stream === "stderr").length;
20
+ const canSubmit = !disabled && command.trim().length > 0;
21
+ const statusLabel = disabled ? "Input disabled" : "Sandboxed";
22
+ const panelClassName = [
23
+ "eth-dev-terminal-panel",
24
+ disabled ? "eth-dev-terminal-panel--disabled" : "",
25
+ className ?? ""
26
+ ]
27
+ .filter(Boolean)
28
+ .join(" ");
29
+
30
+ const submit = (event: React.FormEvent) => {
31
+ event.preventDefault();
32
+ if (!canSubmit) return;
33
+ onCommand?.(command);
34
+ setCommand("");
35
+ };
36
+
37
+ return (
38
+ <section
39
+ {...props}
40
+ className={panelClassName}
41
+ data-eth-component="TerminalPanel"
42
+ role={role}
43
+ aria-label={ariaLabelledBy ? undefined : ariaLabel}
44
+ aria-labelledby={ariaLabelledBy}
45
+ >
46
+ <header className="eth-dev-terminal-panel__header">
47
+ <div className="eth-dev-terminal-panel__heading">
48
+ <p className="eth-dev-terminal-panel__eyebrow">Developer terminal</p>
49
+ <h3>Sandbox terminal</h3>
50
+ <p>Review command output and send scoped commands into the configured sandbox.</p>
51
+ </div>
52
+ <div className="eth-dev-terminal-panel__status" aria-live="polite">
53
+ <span className="eth-dev-terminal-panel__status-indicator" aria-hidden="true" />
54
+ <span>{statusLabel}</span>
55
+ </div>
56
+ </header>
57
+ <dl className="eth-dev-terminal-panel__summary" aria-label="Terminal summary">
58
+ <div>
59
+ <dt>Lines</dt>
60
+ <dd>{lines.length}</dd>
61
+ </div>
62
+ <div>
63
+ <dt>Stderr</dt>
64
+ <dd>{errorCount}</dd>
65
+ </div>
66
+ <div>
67
+ <dt>Prompt</dt>
68
+ <dd>{prompt}</dd>
69
+ </div>
70
+ </dl>
71
+ <div className="eth-dev-terminal-panel__viewport">
72
+ {lines.length ? (
73
+ <pre
74
+ className="eth-dev-terminal-panel__output"
75
+ role="log"
76
+ aria-label="Terminal output"
77
+ aria-live="polite"
78
+ aria-relevant="additions text"
79
+ aria-atomic="false"
80
+ >
81
+ <code className="eth-dev-terminal-panel__output-code">
82
+ {lines.map((line) => (
83
+ <span
84
+ key={line.id}
85
+ className={`eth-dev-terminal-panel__line eth-dev-terminal-panel__line--${line.stream}`}
86
+ >
87
+ <span
88
+ className="eth-dev-terminal-panel__stream"
89
+ aria-label={line.stream === "stderr" ? "Standard error" : "Standard output"}
90
+ >
91
+ {line.stream === "stderr" ? "ERR" : "OUT"}
92
+ </span>
93
+ <span className="eth-dev-terminal-panel__line-text">{line.text}</span>
94
+ </span>
95
+ ))}
96
+ </code>
97
+ </pre>
98
+ ) : (
99
+ <div className="eth-dev-terminal-panel__empty" role="status">
100
+ <h4>No terminal output</h4>
101
+ <p>Command output will appear here when the sandbox emits data.</p>
102
+ </div>
103
+ )}
104
+ </div>
105
+ <form className="eth-dev-terminal-panel__input" onSubmit={submit}>
106
+ <label className="eth-dev-terminal-panel__visually-hidden" htmlFor={inputId}>
107
+ Terminal command
108
+ </label>
109
+ <span className="eth-dev-terminal-panel__prompt" aria-hidden="true">
110
+ {prompt}
111
+ </span>
112
+ <input
113
+ id={inputId}
114
+ value={command}
115
+ disabled={disabled}
116
+ onChange={(event) => setCommand(event.currentTarget.value)}
117
+ autoComplete="off"
118
+ spellCheck={false}
119
+ placeholder={disabled ? "Input disabled" : "Type a command"}
120
+ />
121
+ <Button
122
+ type="submit"
123
+ density="compact"
124
+ intent="primary"
125
+ icon={<Play size={16} />}
126
+ disabled={!canSubmit}
127
+ aria-label="Run command"
128
+ >
129
+ Run
130
+ </Button>
131
+ </form>
132
+ </section>
133
+ );
134
+ }
@@ -0,0 +1,63 @@
1
+ import { render, screen, within } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { TraceTimeline } from "./TraceTimeline";
4
+
5
+ describe("TraceTimeline", () => {
6
+ it("renders trace summary, axis, and accessible span rows", () => {
7
+ render(
8
+ <TraceTimeline
9
+ spans={[
10
+ {
11
+ id: "s1",
12
+ name: "ingest.batch",
13
+ startMs: 0,
14
+ durationMs: 240,
15
+ service: "ingest",
16
+ status: "succeeded"
17
+ },
18
+ {
19
+ id: "s2",
20
+ name: "render.frame",
21
+ startMs: 240,
22
+ durationMs: 1800,
23
+ service: "render",
24
+ parentId: "s1",
25
+ status: "running"
26
+ },
27
+ {
28
+ id: "s3",
29
+ name: "send.email",
30
+ startMs: 2040,
31
+ durationMs: 600,
32
+ service: "send",
33
+ parentId: "s1",
34
+ status: "failed"
35
+ }
36
+ ]}
37
+ />
38
+ );
39
+
40
+ const timeline = screen.getByRole("region", { name: "Trace timeline" });
41
+ expect(within(timeline).getByText("Spans").parentElement?.textContent).toContain("3");
42
+ expect(within(timeline).getByText("2.64 s")).toBeTruthy();
43
+ expect(within(timeline).getByText("1 issue")).toBeTruthy();
44
+ expect(within(timeline).getByText("0 ms")).toBeTruthy();
45
+
46
+ const spans = within(timeline).getByRole("list", { name: "Trace spans" });
47
+ expect(within(spans).getByText("ingest.batch")).toBeTruthy();
48
+ expect(within(spans).getByText("render.frame")).toBeTruthy();
49
+ expect(within(spans).getByText("send.email")).toBeTruthy();
50
+ expect(within(spans).getByText("Failed")).toBeTruthy();
51
+ expect(
52
+ within(spans).getByRole("img", {
53
+ name: "send.email in send, Failed, starts at 2.04 s, duration 600 ms"
54
+ })
55
+ ).toBeTruthy();
56
+ });
57
+
58
+ it("renders an empty state when no spans are available", () => {
59
+ render(<TraceTimeline spans={[]} />);
60
+
61
+ expect(screen.getByRole("status").textContent).toContain("No spans recorded");
62
+ });
63
+ });