2026-01-19 14:39:21 +08:00
|
|
|
import { useCallback, useMemo, useState } from "react"
|
2026-01-19 15:17:03 +08:00
|
|
|
import { useTranslation } from "react-i18next"
|
2026-01-19 14:39:21 +08:00
|
|
|
import jp from "jsonpath"
|
|
|
|
|
import JsonTreeNode from "@/components/json-tree-node"
|
2026-02-24 09:52:34 +08:00
|
|
|
import JsonCodeEditor from "@/components/json-code-editor"
|
2026-02-24 10:32:17 +08:00
|
|
|
import Seo from "@/components/seo"
|
2026-01-19 14:39:21 +08:00
|
|
|
|
|
|
|
|
/**
|
2026-01-19 14:54:27 +08:00
|
|
|
* JSON Viewer page component that displays the JSON visualisation tool in DevLab.
|
2026-01-19 14:39:21 +08:00
|
|
|
*/
|
|
|
|
|
export default function JsonViewer() {
|
2026-01-19 15:17:03 +08:00
|
|
|
const { t } = useTranslation()
|
2026-01-19 14:39:21 +08:00
|
|
|
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<string>(JSON.stringify(initialData, null, 2))
|
|
|
|
|
const [query, setQuery] = useState<string>("$.staff_members[*].name")
|
|
|
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
|
|
2026-02-24 09:55:57 +08:00
|
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
|
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 14:39:21 +08:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 09:55:57 +08:00
|
|
|
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")
|
|
|
|
|
})()
|
2026-01-19 14:39:21 +08:00
|
|
|
|
|
|
|
|
navigator.clipboard.writeText(csv).then(() => {
|
|
|
|
|
setCopied(true)
|
|
|
|
|
setTimeout(() => setCopied(false), 2000)
|
|
|
|
|
})
|
|
|
|
|
}, [query, result.matchedValues])
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full flex gap-4 overflow-hidden">
|
2026-02-24 10:32:17 +08:00
|
|
|
<Seo
|
|
|
|
|
title={t("seo.jsonViewer.title")}
|
|
|
|
|
description={t("seo.jsonViewer.description")}
|
|
|
|
|
path="/json-viewer"
|
|
|
|
|
/>
|
2026-01-19 14:39:21 +08:00
|
|
|
{/* Left panel - 30% */}
|
|
|
|
|
<div className="w-[30%] flex flex-col gap-4 min-h-0">
|
|
|
|
|
{/* JSON Source - fills remaining height */}
|
|
|
|
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden flex-1 flex flex-col min-h-0">
|
|
|
|
|
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 shrink-0">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
2026-01-19 15:17:03 +08:00
|
|
|
{t("jsonViewer.jsonSource")}
|
2026-01-19 14:39:21 +08:00
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-02-24 09:52:34 +08:00
|
|
|
<JsonCodeEditor value={jsonInput} onChange={setJsonInput} />
|
2026-01-19 14:39:21 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* JSONPath Expression - fixed height */}
|
|
|
|
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-4 shrink-0">
|
|
|
|
|
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider mb-2">
|
2026-01-19 15:17:03 +08:00
|
|
|
<span className="text-slate-500">{t("jsonViewer.jsonPathExpression")}</span>
|
2026-01-19 14:39:21 +08:00
|
|
|
{result.queryError && (
|
2026-01-19 15:17:03 +08:00
|
|
|
<span className="text-red-500 normal-case">{t("jsonViewer.invalidSyntax")}</span>
|
2026-01-19 14:39:21 +08:00
|
|
|
)}
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className={`w-full p-3 font-mono text-sm border rounded-lg focus:ring-2 outline-none transition-all shadow-sm ${
|
|
|
|
|
result.queryError
|
|
|
|
|
? "border-red-300 focus:ring-red-500 focus:border-red-500"
|
|
|
|
|
: "border-slate-200 focus:ring-indigo-500 focus:border-indigo-500"
|
|
|
|
|
}`}
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
2026-01-19 15:17:03 +08:00
|
|
|
placeholder={t("jsonViewer.placeholder")}
|
2026-01-19 14:39:21 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Right visualisation panel - 70% */}
|
|
|
|
|
<div className="w-[70%] bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden min-h-0">
|
|
|
|
|
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 flex justify-between items-center shrink-0">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
2026-01-19 15:17:03 +08:00
|
|
|
{t("jsonViewer.visualisedResult")}
|
2026-01-19 14:39:21 +08:00
|
|
|
</span>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs font-medium px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full">
|
2026-01-19 15:17:03 +08:00
|
|
|
{result.matchedPaths.length} {t("jsonViewer.matches")}
|
2026-01-19 14:39:21 +08:00
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={copyAsCsv}
|
|
|
|
|
disabled={result.matchedValues.length === 0 || !!result.error}
|
|
|
|
|
className="text-xs font-medium px-3 py-1 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 disabled:bg-slate-300 disabled:cursor-not-allowed transition-colours"
|
|
|
|
|
>
|
2026-01-19 15:17:03 +08:00
|
|
|
{copied ? t("jsonViewer.copied") : t("jsonViewer.copyAsCsv")}
|
2026-01-19 14:39:21 +08:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 p-6 overflow-auto font-mono text-sm leading-relaxed min-h-0">
|
|
|
|
|
{result.error && (
|
|
|
|
|
<div className="bg-red-50 text-red-600 p-4 rounded-lg border border-red-100 text-xs mb-4">
|
2026-01-19 15:17:03 +08:00
|
|
|
<strong>{t("jsonViewer.error")}</strong> {result.error}
|
2026-01-19 14:39:21 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{result.parsed && (
|
|
|
|
|
<JsonTreeNode
|
|
|
|
|
data={result.parsed}
|
|
|
|
|
path={["$"]}
|
|
|
|
|
matchedPaths={result.matchedPaths}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|