feat: Overhaul Property JSON viewer (#150)

This commit is contained in:
Shorpo 2023-12-14 21:44:00 -07:00 committed by GitHub
parent eb70f053c6
commit 60ee49af89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 1045 additions and 260 deletions

View file

@ -0,0 +1,5 @@
---
'@hyperdx/app': minor
---
Overhaul Properties viewer

View file

@ -1,41 +0,0 @@
import { JSONTree } from 'react-json-tree';
const JSON_TREE_THEME = {
base00: 'rgba(0,0,0,0)',
base02: '#515151',
base03: '#747369',
base04: '#a09f93',
base05: '#d3d0c8',
base06: '#e8e6df',
base07: '#f2f0ec',
base08: '#f2777a',
base09: '#f99157',
base0A: '#ffcc66',
base0F: '#d27b53',
base0E: '#cc99cc',
base0D: '#6699cc',
base0C: '#66cccc',
base0B: '#99cc99',
};
export default function DSJSONTree({
data,
keyPath,
hideRoot = false,
}: {
data: any;
keyPath?: string[];
hideRoot?: boolean;
}) {
return (
// @ts-ignore
<JSONTree
hideRoot={hideRoot}
data={data}
keyPath={keyPath}
shouldExpandNode={() => false}
theme={JSON_TREE_THEME}
invertTheme={false}
/>
);
}

View file

@ -1,13 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import cx from 'classnames';
import { add, format } from 'date-fns';
import Fuse from 'fuse.js';
import get from 'lodash/get';
import isPlainObject from 'lodash/isPlainObject';
import mapValues from 'lodash/mapValues';
import pickBy from 'lodash/pickBy';
import { Form, OverlayTrigger, Tooltip } from 'react-bootstrap';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import Button from 'react-bootstrap/Button';
import CopyToClipboard from 'react-copy-to-clipboard';
import { ErrorBoundary } from 'react-error-boundary';
@ -19,7 +19,15 @@ import { StringParam, withDefault } from 'serialize-query-params';
import stripAnsi from 'strip-ansi';
import Timestamp from 'timestamp-nano';
import { useQueryParam } from 'use-query-params';
import {
ActionIcon,
Group,
Menu,
SegmentedControl,
TextInput,
} from '@mantine/core';
import HyperJson, { GetLineActions, LineAction } from './components/HyperJson';
import { Table } from './components/Table';
import api from './api';
import { CurlGenerator } from './curlGenerator';
@ -100,22 +108,7 @@ function useParsedLogProperties(logData: any): { [key: string]: any } {
return {
// TODO: Users can't search on this via property search so we need to figure out a nice way to handle those search actions...
// TODO: Probably move this into the render below
...mapValues(mergedKvPairs, value => {
if (
typeof value === 'string' &&
value.length > 2 &&
((value[0] === '{' && value[value.length - 1] === '}') ||
(value[0] === '[' && value[value.length - 1] === ']'))
) {
try {
return JSON.parse(value);
} catch (e) {
// do nothing
}
}
return value;
}),
...mergedKvPairs,
...addIfTruthy('span_id', span_id),
...addIfTruthy('trace_id', trace_id),
...addIfTruthy('parent_span_id', parent_span_id),
@ -640,7 +633,7 @@ function TraceSubpanel({
<div className="border-top border-dark mb-4">
{selectedLogData != null ? (
<>
<div className="my-3">
<div className="my-3 text-break">
<div className="text-slate-200 fs-7 mb-2 mt-3">
{selectedLogData.type === 'span' ? 'Span' : 'Log'} Details
</div>
@ -1241,6 +1234,7 @@ function PropertySubpanel({
displayedColumns?: string[];
toggleColumn?: (column: string) => void;
}) {
const router = useRouter();
const [propertySearchValue, setPropertySearchValue] = useState('');
const [isNestedView, setIsNestedView] = useLocalStorage(
'propertySubPanelNestedView',
@ -1342,10 +1336,156 @@ function PropertySubpanel({
}, {} as any);
}, [displayedParsedProperties, propertySearchValue, search]);
const events: any[] | undefined = parsedProperties?.__events;
let events: any[] | undefined;
if (parsedProperties?.__events) {
try {
events = JSON.parse(parsedProperties?.__events);
} catch (e) {
console.warn(e);
}
}
const searchInputRef = useRef<HTMLInputElement>(null);
const [jsonOptions, setJsonOptions] = useLocalStorage(
'logviewer.jsonviewer.options',
{
normallyExpanded: true,
tabulate: true,
lineWrap: true,
useLegacyViewer: false,
},
);
const getLineActions = useCallback<GetLineActions>(
({ keyPath, value }) => {
const actions: LineAction[] = [];
if (onPropertyAddClick != null && typeof value !== 'object') {
actions.push({
key: 'add-to-search',
label: <i className="bi bi-plus-circle" />,
title: 'Add to Search',
onClick: () => {
onPropertyAddClick(`${keyPath.join('.')}`, value);
},
});
}
if (typeof value !== 'object') {
actions.push({
key: 'search',
label: <i className="bi bi-search" />,
title: 'Search for this value only',
onClick: () => {
router.push(
generateSearchUrl(
`${keyPath.join('.')}:${
typeof value === 'string' ? `"${value}"` : value
}`,
),
);
},
});
}
/* TODO: Handle bools properly (they show up as number...) */
if (typeof value === 'number') {
actions.push({
key: 'chart',
label: <i className="bi bi-graph-up" />,
title: 'Chart',
onClick: () => {
router.push(
generateChartUrl({
aggFn: 'avg',
field: `${keyPath.join('.')}`,
groupBy: [],
table: 'logs',
}),
);
},
});
}
if (toggleColumn && typeof value !== 'object') {
const keyPathString = keyPath.join('.');
actions.push({
key: 'toggle-column',
label: <i className="bi bi-table" />,
title: displayedColumns?.includes(keyPathString)
? `Remove ${keyPathString} column from results table`
: `Add ${keyPathString} column to results table`,
onClick: () => toggleColumn(keyPathString),
});
}
const handleCopyObject = () => {
const shouldCopyParent = !isNestedView;
const parentKeyPath = keyPath.slice(0, -1);
const copiedObj = shouldCopyParent
? parentKeyPath.length === 0
? nestedProperties
: get(nestedProperties, parentKeyPath)
: keyPath.length === 0
? nestedProperties
: get(nestedProperties, keyPath);
window.navigator.clipboard.writeText(
JSON.stringify(copiedObj, null, 2),
);
toast.success(
`Copied ${shouldCopyParent ? 'parent' : 'object'} to clipboard`,
);
};
if (typeof value === 'object') {
actions.push(
isNestedView
? {
key: 'copy-object',
label: 'Copy Object',
onClick: handleCopyObject,
}
: {
key: 'copy-parent',
label: 'Copy Parent',
onClick: handleCopyObject,
},
);
} else {
if (!isNestedView) {
actions.push({
key: 'copy-parent',
label: 'Copy Parent',
onClick: handleCopyObject,
});
}
actions.push({
key: 'copy-value',
label: 'Copy Value',
onClick: () => {
window.navigator.clipboard.writeText(
JSON.stringify(value, null, 2),
);
toast.success(`Value copied to clipboard`);
},
});
}
return actions;
},
[
onPropertyAddClick,
toggleColumn,
isNestedView,
router,
generateSearchUrl,
generateChartUrl,
displayedColumns,
nestedProperties,
],
);
return (
<div>
{events != null && events.length > 0 && (
@ -1412,210 +1552,330 @@ function PropertySubpanel({
})}
</>
)}
<div className="fw-bold fs-8 mt-4 d-flex align-items-center mb-2">
<span>Properties</span>
<Button
variant="link"
className="p-0 text-muted-hover fs-8 ms-2"
onClick={() => setIsNestedView(!isNestedView)}
>
Switch to {isNestedView ? 'Flat View' : 'Nested JSON View'}
</Button>
</div>
{isNestedView === false && (
<Form.Control
ref={searchInputRef}
size="sm"
type="text"
placeholder={'Search properties by key or value'}
className="border-0 fs-7.5 mt-2"
value={propertySearchValue}
onChange={e => setPropertySearchValue(e.target.value)}
// autoFocus
onKeyDown={e => {
if (e.key === 'Escape') {
searchInputRef.current?.blur();
}
}}
/>
)}
<div
className="d-flex flex-wrap mt-1 react-json-tree"
style={{ overflowX: 'hidden' }}
>
<JSONTree
hideRoot={true}
shouldExpandNode={() => true}
data={isNestedView ? nestedProperties : filteredProperties}
invertTheme={false}
labelRenderer={keyPath => {
const shouldCopyParent = !isNestedView;
const [key] = keyPath;
const parsedKeyPath = isNestedView
? keyPath
.slice()
.reverse()
.flatMap(key => {
return `${key}`.split('.');
})
: keyPath;
<CollapsibleSection title="Properties" initiallyCollapsed={false}>
<SectionWrapper>
<div
className="px-3 py-1"
style={{
borderBottom: '1px solid #21262C',
}}
>
<Group align="center" position="apart">
<SegmentedControl
size="sm"
data={[
{
label: 'Flat view',
value: 'flat',
},
{
label: 'Nested view',
value: 'nested',
},
]}
value={isNestedView ? 'nested' : 'flat'}
onChange={value => {
setIsNestedView(value === 'nested');
}}
/>
const parentKeyPath = parsedKeyPath.slice(0, -1);
const copiedObj = shouldCopyParent
? parentKeyPath.length === 0
? nestedProperties
: get(nestedProperties, parentKeyPath)
: parsedKeyPath.length === 0
? nestedProperties
: get(nestedProperties, parsedKeyPath);
return (
<OverlayTrigger
trigger="click"
overlay={
<Tooltip id={`tooltip`}>
<CopyToClipboard
text={JSON.stringify(copiedObj, null, 2)}
onCopy={() => {
toast.success(
`${
shouldCopyParent ? 'Parent object' : 'Object'
} copied to clipboard`,
);
}}
>
<Button
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
variant="link"
title={`Copy ${
shouldCopyParent ? 'parent' : ''
} object`}
>
<i className="bi bi-clipboard" /> Copy{' '}
{shouldCopyParent ? 'Parent ' : ''}Object (
{(shouldCopyParent
? parentKeyPath
: parsedKeyPath
).join('.')}
)
</Button>
</CopyToClipboard>
</Tooltip>
}
>
<span className="cursor-pointer">{key}</span>
</OverlayTrigger>
);
}}
valueRenderer={(raw, value, ...rawKeyPath) => {
const keyPath = rawKeyPath.slice().reverse();
const keyPathString = keyPath.join('.');
return (
<div className="parent-hover-trigger d-inline-block px-2">
<pre
className="d-inline text-break"
style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
{raw}
</pre>
<span className="me-2" />
{onPropertyAddClick != null ? (
<Button
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
variant="link"
title="Add to search"
onClick={() => {
onPropertyAddClick(`${keyPath.join('.')}`, value);
<Group position="right" spacing="xs" style={{ flex: 1 }}>
{isNestedView === false && (
<TextInput
style={{ flex: 1 }}
maw={400}
ref={searchInputRef}
size="xs"
variant="filled"
type="text"
placeholder={'Search properties by key or value'}
value={propertySearchValue}
onChange={e => setPropertySearchValue(e.target.value)}
// autoFocus
onKeyDown={e => {
if (e.key === 'Escape') {
searchInputRef.current?.blur();
}
}}
style={{ width: 20 }}
>
<i className="bi bi-plus-circle" />
</Button>
) : null}
{/* The styling here is a huge mess and I'm not sure why its not working */}
<Link
href={generateSearchUrl(
`${keyPath.join('.')}:${
typeof value === 'string' ? `"${value}"` : value
}`,
)}
passHref
>
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
variant="link"
as="a"
title="Search for this value only"
style={{ width: 22 }}
>
<i className="bi bi-search" />
</Button>
</Link>
{/* TODO: Handle bools properly (they show up as number...) */}
{typeof value === 'number' ? (
<Link
href={generateChartUrl({
aggFn: 'avg',
field: `${keyPath.join('.')}`,
groupBy: [],
table: 'logs',
})}
passHref
>
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
variant="link"
as="a"
title="Chart this value"
style={{ width: 20 }}
/>
)}
<Menu width={240}>
<Menu.Target>
<ActionIcon size="md" variant="filled">
<i className="bi bi-gear" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label lh={1} py={6}>
Properties view options
</Menu.Label>
<Menu.Item
disabled={jsonOptions.useLegacyViewer}
onClick={() =>
setJsonOptions({
...jsonOptions,
normallyExpanded: !jsonOptions.normallyExpanded,
})
}
lh="1"
py={8}
rightSection={
jsonOptions.normallyExpanded ? (
<i className="ps-2 bi bi-check2" />
) : null
}
>
<i className="bi bi-graph-up" />
</Button>
</Link>
) : null}
Expand all properties
</Menu.Item>
<Menu.Item
disabled={jsonOptions.useLegacyViewer}
onClick={() =>
setJsonOptions({
...jsonOptions,
lineWrap: !jsonOptions.lineWrap,
})
}
lh="1"
py={8}
rightSection={
jsonOptions.lineWrap ? (
<i className="ps-2 bi bi-check2" />
) : null
}
>
Preserve line breaks
</Menu.Item>
<Menu.Item
lh="1"
py={8}
disabled={jsonOptions.useLegacyViewer}
rightSection={
jsonOptions.tabulate ? (
<i className="ps-2 bi bi-check2" />
) : null
}
onClick={() =>
setJsonOptions({
...jsonOptions,
tabulate: !jsonOptions.tabulate,
})
}
>
Tabulate
</Menu.Item>
<Menu.Item
lh="1"
py={8}
rightSection={
jsonOptions.useLegacyViewer ? (
<i className="ps-2 bi bi-check2" />
) : null
}
onClick={() =>
setJsonOptions({
...jsonOptions,
useLegacyViewer: !jsonOptions.useLegacyViewer,
})
}
>
Use legacy viewer
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</div>
<div
className="d-flex flex-wrap react-json-tree p-3"
style={{ overflowX: 'hidden' }}
>
{/* TODO: Remove old viewer once it passes the test of time... */}
{jsonOptions.useLegacyViewer ? (
<JSONTree
hideRoot={true}
shouldExpandNode={() => true}
data={isNestedView ? nestedProperties : filteredProperties}
invertTheme={false}
labelRenderer={keyPath => {
const shouldCopyParent = !isNestedView;
{!!toggleColumn && keyPath.length === 1 ? (
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
variant="link"
as="a"
title={
displayedColumns?.includes(keyPathString)
? `Remove ${keyPathString} column from results table`
: `Add ${keyPathString} column to results table`
}
style={{ width: 20 }}
onClick={() => toggleColumn(keyPathString)}
>
<i className="bi bi-table" />
</Button>
) : null}
const [key] = keyPath;
const parsedKeyPath = isNestedView
? keyPath
.slice()
.reverse()
.flatMap(key => {
return `${key}`.split('.');
})
: keyPath;
<CopyToClipboard
text={value}
onCopy={() => {
toast.success(`Value copied to clipboard`);
}}
>
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
title="Copy value to clipboard"
variant="link"
>
<i className="bi bi-clipboard" />
</Button>
</CopyToClipboard>
</div>
);
}}
theme={JSON_TREE_THEME}
/>
</div>
const parentKeyPath = parsedKeyPath.slice(0, -1);
const copiedObj = shouldCopyParent
? parentKeyPath.length === 0
? nestedProperties
: get(nestedProperties, parentKeyPath)
: parsedKeyPath.length === 0
? nestedProperties
: get(nestedProperties, parsedKeyPath);
return (
<OverlayTrigger
trigger="click"
overlay={
<Tooltip id={`tooltip`}>
<CopyToClipboard
text={JSON.stringify(copiedObj, null, 2)}
onCopy={() => {
toast.success(
`${
shouldCopyParent ? 'Parent object' : 'Object'
} copied to clipboard`,
);
}}
>
<Button
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
variant="link"
title={`Copy ${
shouldCopyParent ? 'parent' : ''
} object`}
>
<i className="bi bi-clipboard" /> Copy{' '}
{shouldCopyParent ? 'Parent ' : ''}Object (
{(shouldCopyParent
? parentKeyPath
: parsedKeyPath
).join('.')}
)
</Button>
</CopyToClipboard>
</Tooltip>
}
>
<span className="cursor-pointer">{key}</span>
</OverlayTrigger>
);
}}
valueRenderer={(raw, value, ...rawKeyPath) => {
const keyPath = rawKeyPath.slice().reverse();
const keyPathString = keyPath.join('.');
return (
<div className="parent-hover-trigger d-inline-block px-2">
<pre
className="d-inline text-break"
style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
{raw}
</pre>
<span className="me-2" />
{onPropertyAddClick != null ? (
<Button
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
variant="link"
title="Add to search"
onClick={() => {
onPropertyAddClick(`${keyPath.join('.')}`, value);
}}
style={{ width: 20 }}
>
<i className="bi bi-plus-circle" />
</Button>
) : null}
{/* The styling here is a huge mess and I'm not sure why its not working */}
<Link
href={generateSearchUrl(
`${keyPath.join('.')}:${
typeof value === 'string' ? `"${value}"` : value
}`,
)}
passHref
>
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
variant="link"
as="a"
title="Search for this value only"
style={{ width: 22 }}
>
<i className="bi bi-search" />
</Button>
</Link>
{/* TODO: Handle bools properly (they show up as number...) */}
{typeof value === 'number' ? (
<Link
href={generateChartUrl({
aggFn: 'avg',
field: `${keyPath.join('.')}`,
groupBy: [],
table: 'logs',
})}
passHref
>
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
variant="link"
as="a"
title="Chart this value"
style={{ width: 20 }}
>
<i className="bi bi-graph-up" />
</Button>
</Link>
) : null}
{!!toggleColumn && keyPath.length === 1 ? (
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
variant="link"
as="a"
title={
displayedColumns?.includes(keyPathString)
? `Remove ${keyPathString} column from results table`
: `Add ${keyPathString} column to results table`
}
style={{ width: 20 }}
onClick={() => toggleColumn(keyPathString)}
>
<i className="bi bi-table" />
</Button>
) : null}
<CopyToClipboard
text={value}
onCopy={() => {
toast.success(`Value copied to clipboard`);
}}
>
<Button
className="fs-8 text-muted-hover child-hover-trigger p-0"
title="Copy value to clipboard"
variant="link"
>
<i className="bi bi-clipboard" />
</Button>
</CopyToClipboard>
</div>
);
}}
theme={JSON_TREE_THEME}
/>
) : (
<HyperJson
data={isNestedView ? nestedProperties : filteredProperties}
normallyExpanded={jsonOptions.normallyExpanded}
tabulate={jsonOptions.tabulate}
lineWrap={jsonOptions.lineWrap}
getLineActions={getLineActions}
/>
)}
</div>
</SectionWrapper>
</CollapsibleSection>
</div>
);
}

View file

@ -25,7 +25,7 @@ export const CollapsibleSection = ({
return (
<div className="my-3">
<div
className={`d-flex align-items-center mb-1 text-white-hover`}
className={`d-flex align-items-center mb-1 text-white-hover w-50`}
role="button"
onClick={() => setCollapsed(!collapsed)}
>

View file

@ -0,0 +1,230 @@
.container {
display: flex;
height: 100%;
width: 100%;
flex-direction: column;
padding: 0px 4px;
}
.withTabulate {
.key {
min-width: 150px;
}
}
.withLineWrap {
.valueContainer {
.string {
white-space: pre;
}
}
}
.treeNode {
margin-left: 10px;
}
.line {
display: flex;
gap: 10px;
padding: 2px 24px;
// margin: 0 -10px;
align-items: flex-start;
border-radius: 3px;
position: relative;
overflow: hidden;
&:hover {
background-color: #26282b;
.lineMenu {
display: flex;
}
}
&.expanded {
.object,
.array {
color: #999;
}
}
&.expandable {
cursor: pointer;
.key {
margin-left: -10px;
}
.valueContainer {
margin-left: 10px;
word-break: break-all;
}
&:active {
background-color: #1e1f21;
}
}
}
.lineMenu {
display: none;
align-items: center;
position: absolute;
right: 0px;
top: 0px;
height: calc(100% + 1px);
max-height: 24px;
border-bottom: 1px solid #34373e;
}
.lineMenuBtn {
border: 0;
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
color: #b8b7c9;
border-left: 1px solid #444;
padding: 0px 8px;
height: 100%;
&:hover {
background-color: #34373e;
}
&:active {
background-color: #1e1f21;
}
}
.nestedLine {
// margin-bottom: 8px;
}
.clickable {
&:active {
background-color: #1e1f21;
}
}
.hoverable {
position: relative;
cursor: pointer;
padding: 0px 4px;
margin: 0px -4px;
border-radius: 2px;
display: flex;
gap: 6px;
&:hover,
&:focus {
background-color: #26282b;
.hoverContent {
display: flex;
}
}
.hoverContent {
position: absolute;
left: 100%;
background-color: #26282b;
padding-right: 8px;
padding-left: 4px;
}
}
.hoverContent {
display: none;
}
.jsonBtn {
display: block;
color: #b8b7c9;
cursor: pointer;
background-color: #26282b;
font-size: 11px;
border: 1px solid #444;
border-radius: 3px;
padding: 0px 6px;
margin-bottom: 2px;
&:hover {
background-color: #444;
}
&:active {
background-color: #1e1f21;
}
}
.keyContainer {
// min-width: 160px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.key {
align-items: center;
width: auto;
color: #8378ff;
cursor: pointer;
display: flex;
gap: 6px;
// @extend .hoverable;
// @extend .clickable;
i {
color: #555;
margin-right: -4px;
}
}
.valueContainer {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 3px;
max-width: 100%;
}
.value {
max-width: 100%;
white-space: pre-wrap;
word-wrap: anywhere;
overflow-wrap: anywhere;
overflow: hidden;
@extend .hoverable;
@extend .clickable;
}
.string {
color: #a6e22e;
word-break: break-all;
// margin-left: -6px;
&::before,
&::after {
content: '"';
color: #888;
}
}
.number {
color: #ff9900;
}
.boolean {
color: #ff9900;
}
.object {
color: #a6e22e;
font-size: 11px;
}
.array {
color: #a6e22e;
font-size: 11px;
}
.expandMoreProps {
color: #ff9900;
font-weight: 500;
i {
margin-left: 4px;
}
}

View file

@ -0,0 +1,331 @@
import * as React from 'react';
import cx from 'classnames';
import { atom, Provider, useAtomValue, useSetAtom } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';
import {
isArray,
isBoolean,
isNull,
isNumber,
isPlainObject,
isString,
} from 'lodash';
import { useHover } from '@mantine/hooks';
import styles from './HyperJson.module.scss';
export type LineAction = {
key: string;
title?: string;
label: React.ReactNode;
onClick: () => void;
};
export type GetLineActions = (arg0: {
key: string;
keyPath: string[];
value: any;
}) => LineAction[];
// Store common state in an atom so that it can be shared between components
// to avoid prop drilling
type HyperJsonAtom = {
normallyExpanded: boolean;
getLineActions?: GetLineActions;
};
const hyperJsonAtom = atom<HyperJsonAtom>({
normallyExpanded: false,
});
const ValueRenderer = React.memo(({ value }: { value: any }) => {
if (isNull(value)) {
return <span className={styles.null}>null</span>;
}
if (isString(value)) {
return <span className={styles.string}>{value}</span>;
}
if (isNumber(value)) {
return <span className={styles.number}>{value}</span>;
}
if (isBoolean(value)) {
return <span className={styles.boolean}>{value ? 'true' : 'false'}</span>;
}
if (isPlainObject(value)) {
return (
<span className={styles.object}>
{'{}'} {Object.keys(value).length} keys
</span>
);
}
if (isArray(value)) {
return (
<span className={styles.array}>
{'[]'} {value.length} items
</span>
);
}
return null;
});
const LineMenu = React.memo(
({
keyName,
keyPath,
value,
}: {
keyName: string;
keyPath: string[];
value: any;
}) => {
const { getLineActions } = useAtomValue(hyperJsonAtom);
const lineActions = React.useMemo(() => {
if (getLineActions) {
return getLineActions({ key: keyName, keyPath, value });
}
return [];
}, [getLineActions, keyName, keyPath, value]);
return (
<div className={styles.lineMenu}>
{lineActions.map(action => (
<button
key={action.key}
title={action.title}
className={styles.lineMenuBtn}
onClick={e => {
action.onClick();
e.stopPropagation();
}}
>
{action.label}
</button>
))}
</div>
);
},
);
const Line = React.memo(
({
keyName,
keyPath: parentKeyPath,
value,
disableMenu,
}: {
keyName: string;
keyPath: string[];
value: any;
disableMenu: boolean;
}) => {
const { normallyExpanded } = useAtomValue(hyperJsonAtom);
// For performance reasons, render LineMenu only when hovered instead of
// mounting it for potentially hundreds of lines
const { ref, hovered } = useHover<HTMLDivElement>();
const isStringValueJsonLike = React.useMemo(() => {
if (!isString(value)) return false;
return (
(value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']'))
);
}, [value]);
const [isExpanded, setIsExpanded] = React.useState(
normallyExpanded && !isStringValueJsonLike,
);
React.useEffect(() => {
setIsExpanded(normallyExpanded && !isStringValueJsonLike);
}, [isStringValueJsonLike, normallyExpanded]);
const isExpandable = React.useMemo(
() =>
(isPlainObject(value) && Object.keys(value).length > 0) ||
(isArray(value) && value.length > 0) ||
isStringValueJsonLike,
[isStringValueJsonLike, value],
);
const handleToggle = React.useCallback(() => {
if (!isExpandable) return;
setIsExpanded(prev => !prev);
}, [isExpandable]);
const expandedData = React.useMemo(() => {
if (isStringValueJsonLike) {
try {
return JSON.parse(value);
} catch (e) {
return null;
}
}
return value;
}, [isStringValueJsonLike, value]);
const nestedLevel = parentKeyPath.length;
const keyPath = React.useMemo(
() => [...parentKeyPath, keyName],
[keyName, parentKeyPath],
);
return (
<>
<div
ref={ref}
onClick={handleToggle}
className={cx(styles.line, {
[styles.nestedLine]: nestedLevel > 0,
[styles.expanded]: isExpanded,
[styles.expandable]: isExpandable,
})}
style={{ marginLeft: nestedLevel * 16 }}
key={keyName}
>
<div className={styles.keyContainer}>
<div className={styles.key}>
{isExpandable &&
(isExpanded ? (
<i className="bi bi-caret-down-fill fs-9"></i>
) : (
<i className="bi bi-caret-right-fill fs-9"></i>
))}
{keyName}
<div className={styles.hoverContent}>
<i className="bi bi-clipboard" />
</div>
</div>
</div>
<div className={styles.valueContainer}>
{isStringValueJsonLike ? (
isExpanded ? (
<div className={styles.object}>{'{}'} Parsed JSON</div>
) : (
<>
<ValueRenderer value={value} />
<div className={styles.jsonBtn}>Looks like JSON. Parse?</div>
</>
)
) : (
<ValueRenderer value={value} />
)}
</div>
{hovered && !disableMenu && (
<LineMenu keyName={keyName} keyPath={keyPath} value={value} />
)}
</div>
{isExpanded && isExpandable && (
<TreeNode
data={expandedData}
keyPath={keyPath}
disableMenu={isStringValueJsonLike}
/>
)}
</>
);
},
);
const MAX_TREE_NODE_ITEMS = 50;
function TreeNode({
data,
keyPath = [],
disableMenu = false,
}: {
data: object;
keyPath?: string[];
disableMenu?: boolean;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const originalLength = React.useMemo(() => Object.keys(data).length, [data]);
const visibleLines = React.useMemo(() => {
return isExpanded
? Object.entries(data)
: Object.entries(data).slice(0, MAX_TREE_NODE_ITEMS);
}, [data, isExpanded]);
const nestedLevel = keyPath?.length || 0;
return (
<>
{visibleLines.map(([key, value]) => (
<Line
key={key}
keyName={key}
value={value}
keyPath={keyPath}
disableMenu={disableMenu}
/>
))}
{originalLength > MAX_TREE_NODE_ITEMS && !isExpanded && (
<div
className={cx(styles.line, styles.nestedLine, styles.expandable)}
style={{ marginLeft: nestedLevel * 16 }}
onClick={() => setIsExpanded(true)}
>
<div className={styles.keyContainer}>
<div className={styles.jsonBtn}>
Expand {originalLength - MAX_TREE_NODE_ITEMS} more properties
</div>
</div>
</div>
)}
</>
);
}
// Hydrate + allow to use this component multiple times on the same page
const HydrateAtoms = ({
children,
initialValues,
}: {
initialValues: HyperJsonAtom;
children: React.ReactElement;
}) => {
useHydrateAtoms([[hyperJsonAtom, initialValues]]);
const set = useSetAtom(hyperJsonAtom);
React.useEffect(() => {
set(initialValues);
}, [initialValues, set]);
return children;
};
type HyperJsonProps = {
data: object;
normallyExpanded?: boolean;
tabulate?: boolean;
lineWrap?: boolean;
getLineActions?: GetLineActions;
};
const HyperJson = ({
data,
normallyExpanded = false,
tabulate = false,
lineWrap,
getLineActions,
}: HyperJsonProps) => {
const isEmpty = React.useMemo(() => Object.keys(data).length === 0, [data]);
return (
<Provider>
<HydrateAtoms initialValues={{ normallyExpanded, getLineActions }}>
<div
className={cx(styles.container, {
[styles.withTabulate]: tabulate,
[styles.withLineWrap]: lineWrap,
})}
>
{isEmpty ? (
<div className="text-slate-400">Empty</div>
) : (
<TreeNode data={data} />
)}
</div>
</HydrateAtoms>
</Provider>
);
};
export default HyperJson;