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..64df551e5 --- /dev/null +++ b/internal/portal/src/common/JSONViewer/JSONViewer.tsx @@ -0,0 +1,213 @@ +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 allPaths = useMemo(() => { + const paths = new Set(); + collectPaths(data, "$", paths); + return paths; + }, [data]); + + const [expanded, setExpanded] = useState>(() => new Set(allPaths)); + + const allExpanded = expanded.size >= allPaths.size; + + 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}

} +
+ + +
+
+
+ +
+
+ ); +}; + +interface JSONNodeProps { + value: unknown; + path: string; + expanded: Set; + toggleNode: (path: string) => void; +} + +const JSONNode = ({ value, path, expanded, toggleNode }: 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} + path={path} + expanded={expanded} + toggleNode={toggleNode} + /> + ); + } + + if (typeof value === "object") { + const entries = Object.entries(value); + return ( + ({ key: k, value: v, showKey: true }))} + count={entries.length} + path={path} + expanded={expanded} + toggleNode={toggleNode} + /> + ); + } + + return {String(value)}; +}; + +interface CollapsibleEntry { + key: string; + value: unknown; + showKey: boolean; +} + +interface CollapsibleNodeProps { + kind: "object" | "array"; + entries: CollapsibleEntry[]; + count: number; + path: string; + expanded: Set; + toggleNode: (path: string) => void; +} + +const CollapsibleNode = ({ + kind, + entries, + count, + path, + expanded, + toggleNode, +}: CollapsibleNodeProps) => { + const isExpanded = expanded.has(path); + const toggle = useCallback(() => toggleNode(path), [toggleNode, path]); + + const openBracket = kind === "object" ? "{" : "["; + const closeBracket = kind === "object" ? "}" : "]"; + + if (count === 0) { + return ( + + {openBracket}{closeBracket} + + ); + } + + const itemLabel = `${count} ${count === 1 ? "item" : "items"}`; + const arrow = isExpanded ? "\u2191" : "\u2193"; + + if (!isExpanded) { + 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)}
-              
+
)}