Skip to content

Commit 8604a03

Browse files
committed
feat(webapp): click a key in the smart-column sample to fill the path
The sample is now a clickable, syntax-colored JSON tree: clicking a key or array index fills the JSON path field and highlights the active node. Nodes collapse and children are capped so a large blob stays manageable.
1 parent 55586b5 commit 8604a03

2 files changed

Lines changed: 180 additions & 45 deletions

File tree

apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx

Lines changed: 21 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { BoltIcon } from "@heroicons/react/20/solid";
22
import { useEffect, useMemo, useState } from "react";
33
import { useTypedFetcher } from "remix-typedjson";
4-
import { CodeBlock } from "~/components/code/CodeBlock";
54
import { Button } from "~/components/primitives/Buttons";
65
import { Callout } from "~/components/primitives/Callout";
76
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
@@ -19,6 +18,7 @@ import {
1918
type SmartColumnSource,
2019
} from "./runColumns";
2120
import { extractSmartValue, labelFromPath, parseSource } from "./smartColumnData";
21+
import { SmartColumnSample } from "./SmartColumnSample";
2222
import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample";
2323

2424
type AddSmartColumnDialogProps = {
@@ -43,10 +43,6 @@ const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({
4343

4444
const DEFAULT_SOURCE: SmartColumnSource = "payload";
4545

46-
/** Cap the highlighted sample so a large inline blob doesn't stall the modal;
47-
* the full parsed value is still used to resolve the path. */
48-
const MAX_SAMPLE_CHARS = 15000;
49-
5046
export function AddSmartColumnDialog({
5147
open,
5248
editing,
@@ -102,29 +98,6 @@ export function AddSmartColumnDialog({
10298
}
10399
}, [sampleRun, source]);
104100

105-
const sampleJson = useMemo<{ text: string; truncated: boolean } | undefined>(() => {
106-
if (!parsed) return undefined;
107-
if (parsed.state === "offloaded") {
108-
return {
109-
text: "// Offloaded to object storage — too large to sample here.",
110-
truncated: false,
111-
};
112-
}
113-
if (parsed.state === "empty") {
114-
return { text: "// No value for this run.", truncated: false };
115-
}
116-
let text: string;
117-
try {
118-
text = JSON.stringify(parsed.value, null, 2);
119-
} catch {
120-
text = String(parsed.value);
121-
}
122-
if (text.length > MAX_SAMPLE_CHARS) {
123-
return { text: `${text.slice(0, MAX_SAMPLE_CHARS)}\n…`, truncated: true };
124-
}
125-
return { text, truncated: false };
126-
}, [parsed]);
127-
128101
const resolved = useMemo(() => {
129102
if (!parsed || path.trim().length === 0) return undefined;
130103
return extractSmartValue(parsed, path);
@@ -229,26 +202,29 @@ export function AddSmartColumnDialog({
229202
<Paragraph variant="extra-small" className="text-text-dimmed">
230203
Loading…
231204
</Paragraph>
232-
) : sampleJson ? (
233-
<>
234-
<CodeBlock
235-
language="json"
236-
code={sampleJson.text}
237-
maxLines={16}
238-
showLineNumbers={false}
239-
showChrome={false}
240-
showCopyButton={false}
241-
/>
242-
{sampleJson.truncated && (
243-
<Paragraph variant="extra-small" className="text-text-dimmed">
244-
Sample truncated. The full value is still used to resolve the path.
245-
</Paragraph>
246-
)}
247-
</>
248-
) : (
205+
) : !parsed ? (
249206
<Paragraph variant="extra-small" className="text-text-dimmed">
250207
No runs to sample.
251208
</Paragraph>
209+
) : parsed.state === "offloaded" ? (
210+
<Paragraph variant="extra-small" className="text-text-dimmed">
211+
This {source} is offloaded to object storage, too large to sample here.
212+
</Paragraph>
213+
) : parsed.state === "empty" ? (
214+
<Paragraph variant="extra-small" className="text-text-dimmed">
215+
No {source} value for this run.
216+
</Paragraph>
217+
) : (
218+
<>
219+
<SmartColumnSample
220+
value={parsed.value}
221+
activePath={path.trim()}
222+
onSelectPath={setPath}
223+
/>
224+
<Paragraph variant="extra-small" className="text-text-dimmed">
225+
Click a key to use its path.
226+
</Paragraph>
227+
</>
252228
)}
253229
<Paragraph variant="extra-extra-small/dimmed/caps" className="mt-2">
254230
Resolves to
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { useState } from "react";
2+
import { cn } from "~/utils/cn";
3+
4+
/** Max children rendered per node so a large blob can't blow up the DOM. */
5+
const MAX_CHILDREN = 200;
6+
/** Levels auto-expanded; deeper nodes start collapsed and open on click. */
7+
const AUTO_OPEN_DEPTH = 2;
8+
const MAX_STRING = 80;
9+
10+
/**
11+
* A clickable, syntax-colored JSON tree for the smart-column sample. Clicking a
12+
* key (or array index) fills the JSON path field via `onSelectPath`; the value
13+
* currently at `activePath` is highlighted.
14+
*/
15+
export function SmartColumnSample({
16+
value,
17+
activePath,
18+
onSelectPath,
19+
}: {
20+
value: unknown;
21+
activePath: string;
22+
onSelectPath: (path: string) => void;
23+
}) {
24+
return (
25+
<div className="max-h-52 overflow-auto rounded bg-charcoal-900 p-2 font-mono text-xs leading-relaxed">
26+
<JsonNode
27+
name={undefined}
28+
path="$"
29+
value={value}
30+
depth={0}
31+
activePath={activePath}
32+
onSelectPath={onSelectPath}
33+
/>
34+
</div>
35+
);
36+
}
37+
38+
function childPath(parentPath: string, key: string | number): string {
39+
if (typeof key === "number") return `${parentPath}[${key}]`;
40+
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) return `${parentPath}.${key}`;
41+
return `${parentPath}['${key.replace(/'/g, "\\'")}']`;
42+
}
43+
44+
function JsonNode({
45+
name,
46+
path,
47+
value,
48+
depth,
49+
activePath,
50+
onSelectPath,
51+
}: {
52+
name: string | number | undefined;
53+
path: string;
54+
value: unknown;
55+
depth: number;
56+
activePath: string;
57+
onSelectPath: (path: string) => void;
58+
}) {
59+
const [open, setOpen] = useState(depth < AUTO_OPEN_DEPTH);
60+
const isObject = value !== null && typeof value === "object";
61+
const selected = path === activePath;
62+
63+
const keyButton =
64+
name !== undefined ? (
65+
<button
66+
type="button"
67+
onClick={() => onSelectPath(path)}
68+
className={cn(
69+
"rounded px-0.5 text-sky-300 hover:bg-blue-500/20",
70+
selected && "bg-blue-500/30 text-sky-200"
71+
)}
72+
>
73+
{typeof name === "number" ? name : `"${name}"`}
74+
</button>
75+
) : depth === 0 && !isObject ? (
76+
<button
77+
type="button"
78+
onClick={() => onSelectPath("$")}
79+
className={cn(
80+
"rounded px-0.5 text-text-dimmed hover:bg-blue-500/20",
81+
selected && "bg-blue-500/30"
82+
)}
83+
>
84+
$
85+
</button>
86+
) : null;
87+
88+
if (!isObject) {
89+
return (
90+
<div className="whitespace-pre">
91+
{keyButton}
92+
{keyButton && <span className="text-text-dimmed">: </span>}
93+
<PrimitiveValue value={value} />
94+
</div>
95+
);
96+
}
97+
98+
const isArray = Array.isArray(value);
99+
const entries: [string | number, unknown][] = isArray
100+
? (value as unknown[]).map((v, i) => [i, v])
101+
: Object.entries(value as Record<string, unknown>);
102+
const shown = entries.slice(0, MAX_CHILDREN);
103+
const openBrace = isArray ? "[" : "{";
104+
const closeBrace = isArray ? "]" : "}";
105+
106+
return (
107+
<div>
108+
<div className="flex items-start whitespace-pre">
109+
<button
110+
type="button"
111+
onClick={() => setOpen((o) => !o)}
112+
aria-label={open ? "Collapse" : "Expand"}
113+
className="mr-1 w-3 shrink-0 text-text-dimmed hover:text-text-bright"
114+
>
115+
{open ? "▾" : "▸"}
116+
</button>
117+
{keyButton}
118+
{keyButton && <span className="text-text-dimmed">: </span>}
119+
<span className="text-text-dimmed">
120+
{openBrace}
121+
{!open && `… ${closeBrace}`}
122+
{!open && entries.length > 0 && (
123+
<span className="ml-1 text-faint">{`${entries.length} ${isArray ? "items" : "keys"}`}</span>
124+
)}
125+
</span>
126+
</div>
127+
{open && (
128+
<div className="ml-[0.4rem] border-l border-grid-dimmed/50 pl-3">
129+
{shown.map(([key, childValue]) => (
130+
<JsonNode
131+
key={String(key)}
132+
name={key}
133+
path={childPath(path, key)}
134+
value={childValue}
135+
depth={depth + 1}
136+
activePath={activePath}
137+
onSelectPath={onSelectPath}
138+
/>
139+
))}
140+
{entries.length > MAX_CHILDREN && (
141+
<div className="text-text-dimmed">{entries.length - MAX_CHILDREN} more</div>
142+
)}
143+
<div className="text-text-dimmed">{closeBrace}</div>
144+
</div>
145+
)}
146+
</div>
147+
);
148+
}
149+
150+
function PrimitiveValue({ value }: { value: unknown }) {
151+
if (value === null) return <span className="text-purple-400">null</span>;
152+
if (typeof value === "string") {
153+
const truncated = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value;
154+
return <span className="text-green-400">"{truncated}"</span>;
155+
}
156+
if (typeof value === "number") return <span className="text-amber-400">{String(value)}</span>;
157+
if (typeof value === "boolean") return <span className="text-purple-400">{String(value)}</span>;
158+
return <span className="text-text-dimmed">{String(value)}</span>;
159+
}

0 commit comments

Comments
 (0)