/** * JSON (ReJSON) key type renderer — exact port of Angular key-json.component. * Inline JSON tree view with expand/collapse, wrap toggle, copy, download, edit. */ import { useState, useEffect, useCallback } from 'react' import { Box, Button, Tooltip, useMediaQuery, useTheme as useMuiTheme } from '@mui/material' import { ContentCopy, Download, UnfoldMore, UnfoldLess, WrapText, Notes, Edit, ExpandMore, ChevronRight, } from '@mui/icons-material' import { useI18nStore } from '../../../stores/i18n.store' import { useRedisStateStore } from '../../../stores/redis-state.store' import { useCommonStore } from '../../../stores/common.store' import { useOverlayStore } from '../../../stores/overlay.store' import { request } from '../../../stores/socket.service' import { trackPage } from '../../../stores/analytics' import { KeyTypeProps, truncateDisplay, copyValue } from './key-type-base' import JsonEditorDialog from '../../../dialogs/JsonEditorDialog' // --- Inline JSON Tree (reused from JsonViewDialog pattern) --- interface JsonNode { key: string; value: any; type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' children?: JsonNode[]; childCount?: number } function jsonToNode(key: string, value: any): JsonNode { if (value === null) return { key, value: null, type: 'null' } if (Array.isArray(value)) { const children = value.map((item, i) => jsonToNode(String(i), item)) return { key, value, type: 'array', children, childCount: children.length } } if (typeof value === 'object') { const children = Object.keys(value).map(k => jsonToNode(k, value[k])) return { key, value, type: 'object', children, childCount: children.length } } return { key, value, type: typeof value as any } } function formatDisplay(node: JsonNode): string { if (node.type === 'null') return 'null' if (node.type === 'string') return `"${node.value}"` return String(node.value) } function useJsonColors() { const { palette } = useMuiTheme() const isDark = palette.mode === 'dark' return { key: isDark ? 'white' : 'black', string: palette.secondary.main, number: palette.primary.main, boolean: palette.error.main, null: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)', } } function TreeNode({ node, level, expandedKeys, toggleExpand, wrap }: { node: JsonNode; level: number; expandedKeys: Set; toggleExpand: (p: string) => void; wrap: boolean }) { const colors = useJsonColors() const path = `${level}-${node.key}` const isExpandable = node.type === 'object' || node.type === 'array' const isExpanded = expandedKeys.has(path) const valueColor = isExpandable ? undefined : (colors as any)[node.type] ?? 'inherit' return ( <> {isExpandable ? ( toggleExpand(path)} sx={{ width: 24, height: 24, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, opacity: 0.6, }}> {isExpanded ? : } ) : ( )} {node.key} : {isExpandable ? ( !isExpanded ? ( <> {node.type === 'array' ? '[' : '{'} ... {node.type === 'array' ? ']' : '}'} ({node.childCount}) ) : null ) : ( {formatDisplay(node)} )} {isExpandable && isExpanded && node.children?.map((child, i) => ( ))} ) } // --- Main component --- export default function KeyJson({ response, value, valueBuffer, keyName, valueFormat, onRefresh }: KeyTypeProps) { const strings = useI18nStore(s => s.strings) const connection = useRedisStateStore(s => s.connection) const { toast, generalHandleError } = useCommonStore() const overlay = useOverlayStore() const isGtSm = useMediaQuery('(min-width: 960px)') const isReadonly = connection?.readonly === true const [jsonObj, setJsonObj] = useState(undefined) const [treeWrap, setTreeWrap] = useState(true) const [expandedKeys, setExpandedKeys] = useState>(new Set()) const [editorOpen, setEditorOpen] = useState(false) const rootLabel = strings?.label?.tree ?? 'root' useEffect(() => { try { const obj = JSON.parse(value) setJsonObj(obj) setExpandedKeys(new Set([`0-${rootLabel}`])) } catch { setJsonObj(undefined) } }, [value, rootLabel]) const toggleExpand = useCallback((path: string) => { setExpandedKeys(prev => { const next = new Set(prev) if (next.has(path)) next.delete(path); else next.add(path) return next }) }, []) const expandAll = useCallback(() => { if (jsonObj === undefined) return const tree = jsonToNode(rootLabel, jsonObj) const keys = new Set() const collect = (node: JsonNode, level: number) => { const path = `${level}-${node.key}` if (node.type === 'object' || node.type === 'array') { keys.add(path) node.children?.forEach(c => collect(c, level + 1)) } } collect(tree, 0) setExpandedKeys(keys) }, [jsonObj, rootLabel]) const collapseAll = useCallback(() => { setExpandedKeys(new Set([`0-${rootLabel}`])) }, [rootLabel]) const copyVal = useCallback(() => copyValue(value), [value]) const downloadJsonFile = useCallback(() => { const blob = new Blob([value], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url; a.download = `${keyName}.json`; a.click() URL.revokeObjectURL(url) }, [value, keyName]) const handleEditorClose = useCallback(async (result?: { obj: string } | null) => { setEditorOpen(false) if (!result?.obj) return try { const val = typeof result.obj === 'string' ? result.obj : JSON.stringify(result.obj) overlay.show() await request({ action: 'key-json-set', payload: { key: keyName, path: '$', value: val } }) trackPage('/key-json-set') toast(strings?.status?.set) onRefresh() } catch (e) { if (e) generalHandleError(e) } finally { overlay.hide() } }, [keyName, strings, toast, onRefresh, generalHandleError]) const Btn = ({ icon, label, color = 'secondary' as const, onClick }: { icon: React.ReactNode; label: string; color?: 'primary' | 'secondary'; onClick: () => void }) => isGtSm ? ( ) : ( ) const tree = jsonObj !== undefined ? jsonToNode(rootLabel, jsonObj) : null return ( } label={strings?.intention?.copy} onClick={copyVal} /> } label={strings?.intention?.downloadJson ?? 'Download JSON'} onClick={downloadJsonFile} /> } label={strings?.page?.treeControls?.expandAll} onClick={expandAll} /> } label={strings?.page?.treeControls?.collapseAll} onClick={collapseAll} /> : } label={treeWrap ? (strings?.intention?.unwrap ?? 'Unwrap') : (strings?.intention?.wrap ?? 'Wrap')} onClick={() => setTreeWrap(w => !w)} /> {!isReadonly && ( } label={strings?.intention?.jsonViewEditor} color="primary" onClick={() => setEditorOpen(true)} /> )} {tree ? ( ) : ( {truncateDisplay(value)} )} ) }