import { useCallback, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import jp from "jsonpath" import JsonTreeNode from "@/components/json-tree-node" import JsonCodeEditor from "@/components/json-code-editor" import Seo from "@/components/seo" const jsonPathTokenRegex = /(\$)|(\.\.)|(\.)|(\[\*\])|(\[\d+\])|(\[(?:'[^']*'|"[^"]*")\])|(\*)|(@)|(\?)|(\(|\))|([A-Za-z_][\w-]*)/g function escapeHtml(input: string): string { return input .replace(/&/g, "&") .replace(//g, ">") } function getJsonPathTokenClass(token: string): string { if (token === "$" || token === "@") return "text-indigo-600" if (token === "." || token === "..") return "text-slate-400" if (token === "*" || token === "[*]") return "text-violet-600" if (token.startsWith("[") && token.endsWith("]")) return "text-amber-600" if (token === "?" || token === "(" || token === ")") return "text-rose-600" return "text-emerald-600" } function highlightJsonPath(input: string): string { if (!input) return " " let result = "" let lastIndex = 0 for (const match of input.matchAll(jsonPathTokenRegex)) { const token = match[0] const index = match.index ?? 0 result += escapeHtml(input.slice(lastIndex, index)) result += `${escapeHtml(token)}` lastIndex = index + token.length } result += escapeHtml(input.slice(lastIndex)) return result } /** * JSON Viewer page component that displays the JSON visualisation tool in DevLab. */ export default function JsonViewer() { const { t } = useTranslation() const initialData = { centre_id: "LON-01", location: "London", is_active: true, staff_members: [ { id: 101, name: "Alice", roles: ["Admin", "Manager"] }, { id: 102, name: "Bob", roles: ["Developer"] }, ], config: { colour_scheme: "Dark Mode", retention_days: 30, }, } const [jsonInput, setJsonInput] = useState(JSON.stringify(initialData, null, 2)) const [query, setQuery] = useState("$.staff_members[*].name") const [copiedCsv, setCopiedCsv] = useState(false) const [copiedRawJson, setCopiedRawJson] = useState(false) const highlightedQuery = useMemo(() => highlightJsonPath(query), [query]) const isPlainObject = (value: unknown): value is Record => { return value !== null && typeof value === "object" && !Array.isArray(value) } // Compute matching results const result = useMemo(() => { let parsed try { parsed = JSON.parse(jsonInput) } catch (e) { return { parsed: null, matchedPaths: [], matchedValues: [], error: (e as Error).message, queryError: null, } } try { const nodes = jp.nodes(parsed, query) return { parsed, matchedPaths: nodes.map((n) => jp.stringify(n.path)), matchedValues: nodes.map((n) => n.value), error: null, queryError: null, } } catch (e) { // When JSONPath expression is invalid, still display the JSON tree but with no matches return { parsed, matchedPaths: [], matchedValues: [], error: null, queryError: (e as Error).message, } } }, [jsonInput, query]) // Copy as CSV const copyAsCsv = useCallback(() => { if (result.matchedValues.length === 0) return const escapeCsvValue = (val: unknown): string => { const str = typeof val === "object" ? JSON.stringify(val) : String(val) if (str.includes(",") || str.includes('"') || str.includes("\n")) { return `"${str.replace(/"/g, '""')}"` } return str } const objectMatches = result.matchedValues.filter(isPlainObject) const isObjectTable = objectMatches.length > 0 && objectMatches.length === result.matchedValues.length const csv = isObjectTable ? (() => { const columns = Array.from(new Set(objectMatches.flatMap((item) => Object.keys(item)))) const headerRow = columns.map(escapeCsvValue).join(",") const valueRows = objectMatches.map((item) => columns.map((column) => escapeCsvValue(item[column])).join(",") ) return [headerRow, ...valueRows].join("\n") })() : (() => { const header = query const rows = result.matchedValues.map(escapeCsvValue) return [header, ...rows].join("\n") })() navigator.clipboard.writeText(csv).then(() => { setCopiedCsv(true) setTimeout(() => setCopiedCsv(false), 2000) }) }, [query, result.matchedValues]) const copySelectedRawJson = useCallback(() => { if (result.matchedValues.length === 0) return const payload = result.matchedValues.length === 1 ? result.matchedValues[0] : result.matchedValues const json = JSON.stringify(payload, null, 2) navigator.clipboard.writeText(json).then(() => { setCopiedRawJson(true) setTimeout(() => setCopiedRawJson(false), 2000) }) }, [result.matchedValues]) return ( {/* Left panel - 30% */} {/* JSON Source - fills remaining height */} {t("jsonViewer.jsonSource")} {/* JSONPath Expression - fixed height */} {t("jsonViewer.jsonPathExpression")} {result.queryError && ( {t("jsonViewer.invalidSyntax")} )} setQuery(e.target.value)} placeholder={t("jsonViewer.placeholder")} spellCheck={false} /> {/* Right visualisation panel - 70% */} {t("jsonViewer.visualisedResult")} {result.matchedPaths.length} {t("jsonViewer.matches")} {copiedRawJson ? t("jsonViewer.copied") : t("jsonViewer.copyRawJson")} {copiedCsv ? t("jsonViewer.copied") : t("jsonViewer.copyAsCsv")} {result.error && ( {t("jsonViewer.error")} {result.error} )} {result.parsed && ( )} ) }