/** * AdvancedJsonSection - Wrapper for AdvancedJsonViewer with collapsible header * * Features: * - Collapsible section with header * - Integrated search bar in header * - Collapse all / expand all buttons * - Copy button in header * - JsonExpansionContext integration for state persistence * - Custom theme (fontSize: 0.7rem, lineHeight: 16px) */ import { useState, useMemo, useCallback, useRef, useEffect } from "react"; import { ChevronDown, ChevronUp, WrapText, Minus, ArrowRightToLine, } from "lucide-react"; import { Button } from "@/src/components/ui/button"; import { Input } from "@/src/components/ui/input"; import { AdvancedJsonSectionHeader } from "./AdvancedJsonSectionHeader"; import { AdvancedJsonViewer } from "@/src/components/ui/AdvancedJsonViewer/AdvancedJsonViewer"; import { useJsonViewPreferences } from "@/src/components/ui/AdvancedJsonViewer/hooks/useJsonViewPreferences"; import { type MediaReturnType } from "@/src/features/media/validation"; import { type PartialJSONTheme } from "@/src/components/ui/AdvancedJsonViewer/types"; import { buildTreeFromJSON } from "@/src/components/ui/AdvancedJsonViewer/utils/treeStructure"; import { searchInTree } from "@/src/components/ui/AdvancedJsonViewer/utils/searchJson"; export interface AdvancedJsonSectionProps { /** Section title */ title: string; /** Data to display */ data: unknown; /** Field name for JsonExpansionContext ("input", "output", "metadata", etc.) */ field: string; /** Pre-parsed data (to avoid re-parsing) */ parsedData?: unknown; /** Section collapse state (controlled) */ collapsed?: boolean; /** Callback when section collapse state changes */ onToggleCollapse?: () => void; /** Background color for the JSON viewer body */ backgroundColor?: string; /** Background color for the header */ headerBackgroundColor?: string; /** Custom CSS class */ className?: string; /** Hide section if data is null/undefined */ hideIfNull?: boolean; /** Enable search functionality */ enableSearch?: boolean; /** Search placeholder text */ searchPlaceholder?: string; /** Show line numbers */ showLineNumbers?: boolean; /** Enable copy buttons */ enableCopy?: boolean; /** Truncate strings longer than this */ truncateStringsAt?: number | null; /** Loading state */ isLoading?: boolean; /** Media attachments */ media?: MediaReturnType[]; /** Additional control buttons in header */ controlButtons?: React.ReactNode; commentedPaths?: Map>; /** Force virtualization on/off (overrides auto-detection) */ virtualized?: boolean; } export function AdvancedJsonSection({ title, data, field, parsedData, collapsed: controlledCollapsed, onToggleCollapse, backgroundColor, headerBackgroundColor, className, hideIfNull = false, enableSearch = true, searchPlaceholder = "Search JSON...", showLineNumbers = true, enableCopy = true, truncateStringsAt = 100, isLoading = false, media: _media, // TODO: Implement media attachment support controlButtons, commentedPaths, virtualized, }: AdvancedJsonSectionProps) { // String wrap mode state (persisted in localStorage) const { stringWrapMode, setStringWrapMode } = useJsonViewPreferences(); // Section collapse state (different from JSON tree expansion) const [internalCollapsed, setInternalCollapsed] = useState(false); const isCollapseControlled = controlledCollapsed !== undefined && onToggleCollapse !== undefined; const sectionCollapsed = isCollapseControlled ? controlledCollapsed : internalCollapsed; // When virtualized=false (continuous scroll mode), ignore collapse state - always expanded const effectiveCollapsed = virtualized === false ? false : sectionCollapsed; const handleToggleSectionCollapse = () => { // Don't allow toggling in non-virtualized mode if (virtualized === false) return; if (isCollapseControlled) { onToggleCollapse(); } else { setInternalCollapsed(!internalCollapsed); } }; // No more context - AdvancedJsonViewer handles storage directly via field prop // Search state (managed in this component, not in AdvancedJsonViewer) const [searchQuery, setSearchQuery] = useState(""); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); // Debounce search query useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery); setCurrentMatchIndex(0); // Reset to first match when query changes }, 300); return () => clearTimeout(timer); }, [searchQuery]); // Build tree ONCE on mount for row counting only // Expansion state is managed by AdvancedJsonViewer internally const initialTree = useMemo(() => { const effectiveData = parsedData ?? data; if (!effectiveData) { return null; } const tree = buildTreeFromJSON(effectiveData, { rootKey: "root", initialExpansion: true, // Fully expanded for counting }); return tree; }, [parsedData, data]); // Compute total row count (when fully expanded) const totalRowCount = useMemo(() => { const count = initialTree ? initialTree.totalNodeCount : 0; return count; }, [initialTree]); // Compute search matches using the initial tree const searchMatches = useMemo(() => { if ( !debouncedSearchQuery || debouncedSearchQuery.trim() === "" || !initialTree ) { return []; } const matches = searchInTree(initialTree, debouncedSearchQuery, { caseSensitive: false, }); return matches; }, [initialTree, debouncedSearchQuery]); // Handle search navigation (controlled via AdvancedJsonViewer) const handleNextMatch = useCallback(() => { if (searchMatches.length === 0) return; const nextIndex = (currentMatchIndex + 1) % searchMatches.length; setCurrentMatchIndex(nextIndex); }, [searchMatches, currentMatchIndex]); const handlePreviousMatch = useCallback(() => { if (searchMatches.length === 0) return; const prevIndex = currentMatchIndex === 0 ? searchMatches.length - 1 : currentMatchIndex - 1; setCurrentMatchIndex(prevIndex); }, [searchMatches, currentMatchIndex]); const handleClearSearch = useCallback(() => { setSearchQuery(""); setDebouncedSearchQuery(""); setCurrentMatchIndex(0); }, []); // TODO: Expand all / collapse all functionality currently disabled // The expandAllDescendants/collapseAllDescendants utilities cause tree offset // validation errors. Needs investigation into offset recalculation in treeExpansion.ts // before this can be re-enabled. // // Previous implementation attempted to: // 1. Write expansion state to storage // 2. Trigger tree rebuild to read new state // But this caused childOffsets corruption in the tree structure. // Handle string wrap mode cycling: truncate → wrap → nowrap → truncate const handleCycleWrapMode = () => { if (stringWrapMode === "truncate") { setStringWrapMode("wrap"); } else if (stringWrapMode === "wrap") { setStringWrapMode("nowrap"); } else { setStringWrapMode("truncate"); } }; // Handle copy const handleCopy = useCallback( (event?: React.MouseEvent) => { event?.preventDefault(); const jsonString = JSON.stringify(parsedData ?? data, null, 2); void navigator.clipboard.writeText(jsonString); }, [parsedData, data], ); // Custom theme for AdvancedJsonViewer const customTheme: PartialJSONTheme = useMemo( () => ({ fontSize: "0.7rem", lineHeight: 16, indentSize: 20, background: headerBackgroundColor || backgroundColor || "transparent", }), [headerBackgroundColor, backgroundColor], ); // Determine if virtualization is being used // Auto-virtualize for 500+ rows (when fully expanded) const isVirtualized = useMemo(() => { const virtualized = totalRowCount >= 500; return virtualized; }, [totalRowCount]); // Ref for scroll container (the body wrapper div) const scrollContainerRef = useRef(null); // Hide section if data is null/undefined if (hideIfNull && (data === null || data === undefined)) { return null; } // If data is still being loaded/parsed, show header but not body const hasData = (parsedData ?? data) !== undefined; const effectiveData = parsedData ?? data; return (
{/* Header with fixed height */}
{title} {totalRowCount} rows{isVirtualized ? " (virtualized)" : ""}
} handleOnCopy={handleCopy} backgroundColor={headerBackgroundColor} onToggleCollapse={handleToggleSectionCollapse} sectionCollapsed={effectiveCollapsed} controlButtons={ <> {/* Search */} {!effectiveCollapsed && enableSearch && (
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (e.shiftKey) { handlePreviousMatch(); } else { handleNextMatch(); } } else if (e.key === "Escape") { handleClearSearch(); } }} className="h-6 w-[180px] pr-16 text-xs" aria-label="Search JSON" /> {searchQuery && ( {searchMatches.length > 0 ? `${currentMatchIndex + 1} of ${searchMatches.length}` : "No matches"} )}
{searchQuery && searchMatches.length > 0 && ( <> )}
)} {/* Custom control buttons */} {!effectiveCollapsed && controlButtons} {/* String wrap mode toggle */} {!effectiveCollapsed && ( )} {/* TODO: Collapse/Expand All button hidden - see comment above for details {!sectionCollapsed && ( )} */} } />
{/* Body */} {!effectiveCollapsed && (
{!hasData ? (
{isLoading ? "Loading..." : "No data"}
) : ( )}
)} ); }