From 6e322a6908cd5a6976b7dc718e3087b9c5d0cf94 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 31 Mar 2026 03:45:51 +0700 Subject: [PATCH 1/2] feat(portal): add JSON viewer with syntax highlighting and copy support Replace raw `
` + `JSON.stringify` blocks with a collapsible,
syntax-highlighted JSON viewer component. Also fix the `include` query
param format to use bracket syntax expected by the backend.

Closes #323
Closes #322

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 .../src/common/JSONViewer/JSONViewer.scss     |  98 ++++++++++
 .../src/common/JSONViewer/JSONViewer.tsx      | 181 ++++++++++++++++++
 .../src/scenes/Destination/Destination.tsx    |   9 +-
 .../Destination/Events/AttemptDetails.tsx     |  18 +-
 4 files changed, 289 insertions(+), 17 deletions(-)
 create mode 100644 internal/portal/src/common/JSONViewer/JSONViewer.scss
 create mode 100644 internal/portal/src/common/JSONViewer/JSONViewer.tsx

diff --git a/internal/portal/src/common/JSONViewer/JSONViewer.scss b/internal/portal/src/common/JSONViewer/JSONViewer.scss
new file mode 100644
index 000000000..5c2273fee
--- /dev/null
+++ b/internal/portal/src/common/JSONViewer/JSONViewer.scss
@@ -0,0 +1,98 @@
+.json-viewer {
+  &__header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    > h3 {
+      margin: 0;
+    }
+  }
+
+  &__actions {
+    display: flex;
+    align-items: center;
+    gap: var(--spacing-2);
+  }
+
+  &__expand-all {
+    background: none;
+    border: none;
+    padding: var(--spacing-1) var(--spacing-2);
+    cursor: pointer;
+    color: var(--colors-foreground-neutral-3);
+    border-radius: var(--radius-s);
+
+    &:hover {
+      color: var(--colors-foreground-neutral-2);
+      background: var(--colors-background-hover);
+    }
+  }
+
+  &__content {
+    background-color: var(--colors-background-neutral-2);
+    padding: var(--spacing-3);
+    border-radius: var(--radius-s);
+    margin-top: var(--spacing-3);
+    overflow-x: auto;
+  }
+
+  &__summary {
+    background: none;
+    border: none;
+    padding: 0;
+    cursor: pointer;
+    font-family: var(--font-family-monospace);
+    font-size: inherit;
+    line-height: 1.6;
+    display: inline;
+
+    &:hover &-count {
+      color: var(--colors-foreground-neutral-2);
+    }
+  }
+
+  &__summary-count {
+    color: var(--colors-foreground-neutral-3);
+  }
+
+  &__entries {
+    padding-left: var(--spacing-5);
+  }
+
+  &__entry {
+    line-height: 1.6;
+  }
+
+  &__key {
+    color: var(--colors-foreground-neutral);
+  }
+
+  &__colon {
+    color: var(--colors-foreground-neutral-3);
+  }
+
+  &__comma {
+    color: var(--colors-foreground-neutral-3);
+  }
+
+  &__bracket {
+    color: var(--colors-foreground-neutral-3);
+  }
+
+  &__string {
+    color: var(--colors-code-string);
+  }
+
+  &__number {
+    color: var(--colors-code-number);
+  }
+
+  &__boolean {
+    color: var(--colors-code-boolean);
+  }
+
+  &__null {
+    color: var(--colors-code-null);
+  }
+}
diff --git a/internal/portal/src/common/JSONViewer/JSONViewer.tsx b/internal/portal/src/common/JSONViewer/JSONViewer.tsx
new file mode 100644
index 000000000..cef04c039
--- /dev/null
+++ b/internal/portal/src/common/JSONViewer/JSONViewer.tsx
@@ -0,0 +1,181 @@
+import { useState, useCallback } from "react";
+import { CopyButton } from "../CopyButton/CopyButton";
+
+import "./JSONViewer.scss";
+
+interface JSONViewerProps {
+  data: unknown;
+  label?: string;
+}
+
+const JSONViewer = ({ data, label }: JSONViewerProps) => {
+  const [expandAllKey, setExpandAllKey] = useState(0);
+  const [isExpanded, setIsExpanded] = useState(true);
+
+  const handleExpandAll = () => {
+    setIsExpanded(true);
+    setExpandAllKey((k) => k + 1);
+  };
+
+  const handleCollapseAll = () => {
+    setIsExpanded(false);
+    setExpandAllKey((k) => k + 1);
+  };
+
+  return (
+    
+
+ {label &&

{label}

} +
+ + +
+
+
+ +
+
+ ); +}; + +interface JSONNodeProps { + value: unknown; + depth: number; + defaultExpanded?: boolean; +} + +const JSONNode = ({ value, depth, defaultExpanded = false }: JSONNodeProps) => { + if (value === null) { + return null; + } + + if (typeof value === "boolean") { + return ( + {value ? "true" : "false"} + ); + } + + if (typeof value === "number") { + return {value}; + } + + if (typeof value === "string") { + return "{value}"; + } + + if (Array.isArray(value)) { + return ( + ({ + key: String(i), + value: item, + showKey: false, + }))} + count={value.length} + depth={depth} + defaultExpanded={defaultExpanded} + /> + ); + } + + if (typeof value === "object") { + const entries = Object.entries(value); + return ( + ({ key: k, value: v, showKey: true }))} + count={entries.length} + depth={depth} + defaultExpanded={defaultExpanded} + /> + ); + } + + return {String(value)}; +}; + +interface CollapsibleEntry { + key: string; + value: unknown; + showKey: boolean; +} + +interface CollapsibleNodeProps { + kind: "object" | "array"; + entries: CollapsibleEntry[]; + count: number; + depth: number; + defaultExpanded: boolean; +} + +const CollapsibleNode = ({ + kind, + entries, + count, + depth, + defaultExpanded, +}: CollapsibleNodeProps) => { + const [expanded, setExpanded] = useState(defaultExpanded); + const toggle = useCallback(() => setExpanded((e) => !e), []); + + const openBracket = kind === "object" ? "{" : "["; + const closeBracket = kind === "object" ? "}" : "]"; + + if (count === 0) { + return ( + + {openBracket}{closeBracket} + + ); + } + + const itemLabel = `${count} ${count === 1 ? "item" : "items"}`; + const arrow = expanded ? "\u2191" : "\u2193"; + + if (!expanded) { + return ( + + ); + } + + return ( + + +
+ {entries.map((entry, i) => ( +
+ {entry.showKey && ( + <> + "{entry.key}" + : + + )} + + {i < entries.length - 1 && ( + , + )} +
+ ))} +
+ {closeBracket} +
+ ); +}; + +export default JSONViewer; diff --git a/internal/portal/src/scenes/Destination/Destination.tsx b/internal/portal/src/scenes/Destination/Destination.tsx index 1d3260480..f5e42dd91 100644 --- a/internal/portal/src/scenes/Destination/Destination.tsx +++ b/internal/portal/src/scenes/Destination/Destination.tsx @@ -5,6 +5,7 @@ import useSWR from "swr"; import Badge from "../../common/Badge/Badge"; import { CopyButton } from "../../common/CopyButton/CopyButton"; +import JSONViewer from "../../common/JSONViewer/JSONViewer"; import { Loading } from "../../common/Icons"; import CONFIGS from "../../config"; import { useDestinationType } from "../../destination-types"; @@ -224,10 +225,10 @@ const Destination = () => { destination.filter && Object.keys(destination.filter).length > 0 && (
-

Event Filter

-
-                          {JSON.stringify(destination.filter, null, 2)}
-                        
+
)} diff --git a/internal/portal/src/scenes/Destination/Events/AttemptDetails.tsx b/internal/portal/src/scenes/Destination/Events/AttemptDetails.tsx index a5147b3f1..9fc4d1890 100644 --- a/internal/portal/src/scenes/Destination/Events/AttemptDetails.tsx +++ b/internal/portal/src/scenes/Destination/Events/AttemptDetails.tsx @@ -6,6 +6,7 @@ import { Attempt, EventFull } from "../../../typings/Event"; import Badge from "../../../common/Badge/Badge"; import RetryDeliveryButton from "../../../common/RetryDeliveryButton/RetryDeliveryButton"; import { CopyButton } from "../../../common/CopyButton/CopyButton"; +import JSONViewer from "../../../common/JSONViewer/JSONViewer"; const AttemptDetails = ({ navigateAttempt, @@ -15,7 +16,7 @@ const AttemptDetails = ({ const { attempt_id: attemptId, destination_id: destinationId } = useParams(); const { data: attempt } = useSWR( - `destinations/${destinationId}/attempts/${attemptId}?include=event.data,response_data`, + `destinations/${destinationId}/attempts/${attemptId}?include[]=event.data&include[]=response_data`, ); if (!attempt) { @@ -119,28 +120,19 @@ const AttemptDetails = ({ {event?.data && (
-

Data

-
-                {JSON.stringify(event.data, null, 2)}
-              
+
)} {event?.metadata && Object.keys(event.metadata).length > 0 && (
-

Metadata

-
-                {JSON.stringify(event.metadata, null, 2)}
-              
+
)} {attempt.response_data && (
-

Response

-
-                {JSON.stringify(attempt.response_data, null, 2)}
-              
+
)} From e0e3d14727174c2bc5b37c13197857647d4acc45 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Tue, 31 Mar 2026 04:10:26 +0700 Subject: [PATCH 2/2] fix(portal): lift expand state to top level for reliable collapse/expand all Move expand/collapse state from individual nodes to a centralized Set of expanded paths. Fixes button label not reflecting actual node state after manual toggles. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/common/JSONViewer/JSONViewer.tsx | 96 ++++++++++++------- 1 file changed, 64 insertions(+), 32 deletions(-) diff --git a/internal/portal/src/common/JSONViewer/JSONViewer.tsx b/internal/portal/src/common/JSONViewer/JSONViewer.tsx index cef04c039..64df551e5 100644 --- a/internal/portal/src/common/JSONViewer/JSONViewer.tsx +++ b/internal/portal/src/common/JSONViewer/JSONViewer.tsx @@ -1,43 +1,65 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import { CopyButton } from "../CopyButton/CopyButton"; import "./JSONViewer.scss"; +function collectPaths(value: unknown, path: string, out: Set) { + if (value === null || typeof value !== "object") return; + const entries = Array.isArray(value) + ? value.map((v, i) => [String(i), v] as const) + : Object.entries(value); + if (entries.length === 0) return; + out.add(path); + for (const [k, v] of entries) { + collectPaths(v, `${path}.${k}`, out); + } +} + interface JSONViewerProps { data: unknown; label?: string; } const JSONViewer = ({ data, label }: JSONViewerProps) => { - const [expandAllKey, setExpandAllKey] = useState(0); - const [isExpanded, setIsExpanded] = useState(true); + const allPaths = useMemo(() => { + const paths = new Set(); + collectPaths(data, "$", paths); + return paths; + }, [data]); - const handleExpandAll = () => { - setIsExpanded(true); - setExpandAllKey((k) => k + 1); - }; + const [expanded, setExpanded] = useState>(() => new Set(allPaths)); + + const allExpanded = expanded.size >= allPaths.size; - const handleCollapseAll = () => { - setIsExpanded(false); - setExpandAllKey((k) => k + 1); + const handleToggle = () => { + setExpanded(allExpanded ? new Set() : new Set(allPaths)); }; + const toggleNode = useCallback((path: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + return (
{label &&

{label}

}
-
- +
); @@ -45,11 +67,12 @@ const JSONViewer = ({ data, label }: JSONViewerProps) => { interface JSONNodeProps { value: unknown; - depth: number; - defaultExpanded?: boolean; + path: string; + expanded: Set; + toggleNode: (path: string) => void; } -const JSONNode = ({ value, depth, defaultExpanded = false }: JSONNodeProps) => { +const JSONNode = ({ value, path, expanded, toggleNode }: JSONNodeProps) => { if (value === null) { return null; } @@ -78,8 +101,9 @@ const JSONNode = ({ value, depth, defaultExpanded = false }: JSONNodeProps) => { showKey: false, }))} count={value.length} - depth={depth} - defaultExpanded={defaultExpanded} + path={path} + expanded={expanded} + toggleNode={toggleNode} /> ); } @@ -91,8 +115,9 @@ const JSONNode = ({ value, depth, defaultExpanded = false }: JSONNodeProps) => { kind="object" entries={entries.map(([k, v]) => ({ key: k, value: v, showKey: true }))} count={entries.length} - depth={depth} - defaultExpanded={defaultExpanded} + path={path} + expanded={expanded} + toggleNode={toggleNode} /> ); } @@ -110,19 +135,21 @@ interface CollapsibleNodeProps { kind: "object" | "array"; entries: CollapsibleEntry[]; count: number; - depth: number; - defaultExpanded: boolean; + path: string; + expanded: Set; + toggleNode: (path: string) => void; } const CollapsibleNode = ({ kind, entries, count, - depth, - defaultExpanded, + path, + expanded, + toggleNode, }: CollapsibleNodeProps) => { - const [expanded, setExpanded] = useState(defaultExpanded); - const toggle = useCallback(() => setExpanded((e) => !e), []); + const isExpanded = expanded.has(path); + const toggle = useCallback(() => toggleNode(path), [toggleNode, path]); const openBracket = kind === "object" ? "{" : "["; const closeBracket = kind === "object" ? "}" : "]"; @@ -136,9 +163,9 @@ const CollapsibleNode = ({ } const itemLabel = `${count} ${count === 1 ? "item" : "items"}`; - const arrow = expanded ? "\u2191" : "\u2193"; + const arrow = isExpanded ? "\u2191" : "\u2193"; - if (!expanded) { + if (!isExpanded) { return (