Files
yaak/src-web/components/core/JsonAttributeTree.tsx
2025-11-23 08:38:13 -08:00

147 lines
4.4 KiB
TypeScript

import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { Icon } from './Icon';
interface Props {
depth?: number;
// biome-ignore lint/suspicious/noExplicitAny: none
attrValue: any;
attrKey?: string | number;
attrKeyJsonPath?: string;
className?: string;
}
export const JsonAttributeTree = ({
depth = 0,
attrKey,
attrValue,
attrKeyJsonPath,
className,
}: Props) => {
attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => setIsExpanded((v) => !v);
const { isExpandable, children, label, labelClassName } = useMemo<{
isExpandable: boolean;
children: ReactNode;
label?: string;
labelClassName?: string;
}>(() => {
const jsonType = Object.prototype.toString.call(attrValue);
if (jsonType === '[object Object]') {
return {
children: isExpanded
? Object.keys(attrValue)
.sort((a, b) => a.localeCompare(b))
.flatMap((k) => (
<JsonAttributeTree
key={k}
depth={depth + 1}
attrValue={attrValue[k]}
attrKey={k}
attrKeyJsonPath={joinObjectKey(attrKeyJsonPath, k)}
/>
))
: null,
isExpandable: Object.keys(attrValue).length > 0,
label: isExpanded ? `{${Object.keys(attrValue).length || ' '}}` : '{⋯}',
labelClassName: 'text-text-subtlest',
};
}
if (jsonType === '[object Array]') {
return {
children: isExpanded
? // biome-ignore lint/suspicious/noExplicitAny: none
attrValue.flatMap((v: any, i: number) => (
<JsonAttributeTree
// biome-ignore lint/suspicious/noArrayIndexKey: none
key={i}
depth={depth + 1}
attrValue={v}
attrKey={i}
attrKeyJsonPath={joinArrayKey(attrKeyJsonPath, i)}
/>
))
: null,
isExpandable: attrValue.length > 0,
label: isExpanded ? `[${attrValue.length || ' '}]` : '[⋯]',
labelClassName: 'text-text-subtlest',
};
}
return {
children: null,
isExpandable: false,
label: jsonType === '[object String]' ? `"${attrValue}"` : `${attrValue}`,
labelClassName: classNames(
jsonType === '[object Boolean]' && 'text-primary',
jsonType === '[object Number]' && 'text-info',
jsonType === '[object String]' && 'text-notice',
jsonType === '[object Null]' && 'text-danger',
),
};
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
const labelEl = (
<span
className={classNames(labelClassName, 'cursor-text select-text group-hover:text-text-subtle')}
>
{label}
</span>
);
return (
<div
className={classNames(
className,
/*depth === 0 && '-ml-4',*/ 'font-mono text-xs',
depth === 0 && 'h-full overflow-y-auto pb-2',
)}
>
<div className="flex items-center">
{isExpandable ? (
<button
type="button"
className="group relative flex items-center pl-4 w-full"
onClick={toggleExpanded}
>
<Icon
size="xs"
icon="chevron_right"
className={classNames(
'left-0 absolute transition-transform flex items-center',
'group-hover:text-text-subtle',
isExpanded ? 'rotate-90' : '',
)}
/>
<span className="text-primary group-hover:text-primary mr-1.5 whitespace-nowrap">
{attrKey === undefined ? '$' : attrKey}:
</span>
{labelEl}
</button>
) : (
<>
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap cursor-text select-text">
{attrKey}:
</span>
{labelEl}
</>
)}
</div>
{children && <div className="ml-4 whitespace-nowrap">{children}</div>}
</div>
);
};
function joinObjectKey(baseKey: string | undefined, key: string): string {
const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
if (baseKey == null) return quotedKey;
return `${baseKey}.${quotedKey}`;
}
function joinArrayKey(baseKey: string | undefined, index: number): string {
return `${baseKey ?? ''}[${index}]`;
}