Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions internal/portal/src/common/JSONViewer/JSONViewer.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
213 changes: 213 additions & 0 deletions internal/portal/src/common/JSONViewer/JSONViewer.tsx
Original file line number Diff line number Diff line change
@@ -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<string>) {
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<string>();
collectPaths(data, "$", paths);
return paths;
}, [data]);

const [expanded, setExpanded] = useState<Set<string>>(() => 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 (
<div className="json-viewer">
<div className="json-viewer__header">
{label && <h3 className="subtitle-m">{label}</h3>}
<div className="json-viewer__actions">
<button className="json-viewer__expand-all mono-xs" onClick={handleToggle}>
{allExpanded ? "Collapse all" : "Expand all"}
</button>
<CopyButton value={JSON.stringify(data, null, 2)} />
</div>
</div>
<div className="json-viewer__content mono-s">
<JSONNode value={data} path="$" expanded={expanded} toggleNode={toggleNode} />
</div>
</div>
);
};

interface JSONNodeProps {
value: unknown;
path: string;
expanded: Set<string>;
toggleNode: (path: string) => void;
}

const JSONNode = ({ value, path, expanded, toggleNode }: JSONNodeProps) => {
if (value === null) {
return <span className="json-viewer__null">null</span>;
}

if (typeof value === "boolean") {
return (
<span className="json-viewer__boolean">{value ? "true" : "false"}</span>
);
}

if (typeof value === "number") {
return <span className="json-viewer__number">{value}</span>;
}

if (typeof value === "string") {
return <span className="json-viewer__string">"{value}"</span>;
}

if (Array.isArray(value)) {
return (
<CollapsibleNode
kind="array"
entries={value.map((item, i) => ({
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 (
<CollapsibleNode
kind="object"
entries={entries.map(([k, v]) => ({ key: k, value: v, showKey: true }))}
count={entries.length}
path={path}
expanded={expanded}
toggleNode={toggleNode}
/>
);
}

return <span>{String(value)}</span>;
};

interface CollapsibleEntry {
key: string;
value: unknown;
showKey: boolean;
}

interface CollapsibleNodeProps {
kind: "object" | "array";
entries: CollapsibleEntry[];
count: number;
path: string;
expanded: Set<string>;
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 (
<span className="json-viewer__bracket">
{openBracket}{closeBracket}
</span>
);
}

const itemLabel = `${count} ${count === 1 ? "item" : "items"}`;
const arrow = isExpanded ? "\u2191" : "\u2193";

if (!isExpanded) {
return (
<button className="json-viewer__summary" onClick={toggle}>
<span className="json-viewer__bracket">{openBracket}</span>
{" "}
<span className="json-viewer__summary-count">{itemLabel} {arrow}</span>
{" "}
<span className="json-viewer__bracket">{closeBracket}</span>
</button>
);
}

return (
<span className="json-viewer__node">
<button className="json-viewer__summary" onClick={toggle}>
<span className="json-viewer__bracket">{openBracket}</span>
{" "}
<span className="json-viewer__summary-count">{itemLabel} {arrow}</span>
</button>
<div className="json-viewer__entries">
{entries.map((entry, i) => (
<div key={entry.key} className="json-viewer__entry">
{entry.showKey && (
<>
<span className="json-viewer__key">"{entry.key}"</span>
<span className="json-viewer__colon">: </span>
</>
)}
<JSONNode
value={entry.value}
path={`${path}.${entry.key}`}
expanded={expanded}
toggleNode={toggleNode}
/>
{i < entries.length - 1 && (
<span className="json-viewer__comma">,</span>
)}
</div>
))}
</div>
<span className="json-viewer__bracket">{closeBracket}</span>
</span>
);
};

export default JSONViewer;
9 changes: 5 additions & 4 deletions internal/portal/src/scenes/Destination/Destination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -224,10 +225,10 @@ const Destination = () => {
destination.filter &&
Object.keys(destination.filter).length > 0 && (
<div className="filter-container">
<h2 className="title-l">Event Filter</h2>
<pre className="filter-json mono-s">
{JSON.stringify(destination.filter, null, 2)}
</pre>
<JSONViewer
data={destination.filter}
label="Event Filter"
/>
</div>
)}
<DestinationMetrics destination={destination} />
Expand Down
18 changes: 5 additions & 13 deletions internal/portal/src/scenes/Destination/Events/AttemptDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,7 +16,7 @@ const AttemptDetails = ({
const { attempt_id: attemptId, destination_id: destinationId } = useParams();

const { data: attempt } = useSWR<Attempt>(
`destinations/${destinationId}/attempts/${attemptId}?include=event.data,response_data`,
`destinations/${destinationId}/attempts/${attemptId}?include[]=event.data&include[]=response_data`,
);

if (!attempt) {
Expand Down Expand Up @@ -119,28 +120,19 @@ const AttemptDetails = ({

{event?.data && (
<div className="attempt-data__section">
<h3 className="subtitle-m">Data</h3>
<pre className="mono-s">
{JSON.stringify(event.data, null, 2)}
</pre>
<JSONViewer data={event.data} label="Data" />
</div>
)}

{event?.metadata && Object.keys(event.metadata).length > 0 && (
<div className="attempt-data__section">
<h3 className="subtitle-m">Metadata</h3>
<pre className="mono-s">
{JSON.stringify(event.metadata, null, 2)}
</pre>
<JSONViewer data={event.metadata} label="Metadata" />
</div>
)}

{attempt.response_data && (
<div className="attempt-data__section">
<h3 className="subtitle-m">Response</h3>
<pre className="mono-s">
{JSON.stringify(attempt.response_data, null, 2)}
</pre>
<JSONViewer data={attempt.response_data} label="Response" />
</div>
)}
</div>
Expand Down
Loading