|
| 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