JSON viewer: block layout, dark/light theme, collapse-all, jq clear button

- Rewrite jsonview.html with block-based node rendering (no rightward staircase)
- CSS custom properties for live theme switching via setTheme(isDark) JS call
- TablePage subscribes to App.ThemeChanged and calls setTheme on switch
- Add collapse-all / expand-all toolbar buttons
- jq filter input uses type=search for native clear (×) button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gerwin Kuijntjes
2026-03-31 14:47:39 +02:00
parent 2193026fd9
commit a390a07d62
3 changed files with 316 additions and 29 deletions

View File

@@ -2,31 +2,11 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:converters="clr-namespace:Xamarin.Neo4j.Converters;assembly=Xamarin.Neo4j" Title="JSON View"
Title="{Binding Title}"
x:Class="Xamarin.Neo4j.Pages.TablePage"> x:Class="Xamarin.Neo4j.Pages.TablePage">
<ContentPage.Resources>
<ResourceDictionary>
<converters:JsonConvertConverter x:Key="jsonConvert" />
</ResourceDictionary>
</ContentPage.Resources>
<ContentPage.Content> <ContentPage.Content>
<CarouselView ItemsSource="{Binding Rows}" Position="{Binding Position}" HorizontalScrollBarVisibility="Never" VerticalScrollBarVisibility="Never"> <WebView x:Name="jsonWebView"
<CarouselView.ItemTemplate> HorizontalOptions="FillAndExpand"
<DataTemplate> VerticalOptions="FillAndExpand" />
<CollectionView ItemsSource="{Binding .}">
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout Margin="0, 0, 0, 25" Padding="10">
<Label Text="{Binding Key, StringFormat='{0}:'}" FontAttributes="Bold" />
<Label Text="{Binding Value, Converter={StaticResource jsonConvert}}" FontSize="Small" />
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</DataTemplate>
</CarouselView.ItemTemplate>
</CarouselView>
</ContentPage.Content> </ContentPage.Content>
</ContentPage> </ContentPage>

View File

@@ -1,13 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Reflection;
using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls; using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Xaml; using Microsoft.Maui.Controls.Xaml;
using Newtonsoft.Json;
using Xamarin.Neo4j.Models; using Xamarin.Neo4j.Models;
using Xamarin.Neo4j.ViewModels;
namespace Xamarin.Neo4j.Pages namespace Xamarin.Neo4j.Pages
{ {
@@ -17,8 +17,72 @@ namespace Xamarin.Neo4j.Pages
public TablePage(QueryResult queryResult) public TablePage(QueryResult queryResult)
{ {
InitializeComponent(); InitializeComponent();
LoadJson(queryResult);
}
BindingContext = new TableViewModel(Navigation, queryResult); protected override void OnAppearing()
{
base.OnAppearing();
App.ThemeChanged += OnThemeChanged;
}
protected override void OnDisappearing()
{
App.ThemeChanged -= OnThemeChanged;
base.OnDisappearing();
}
private void OnThemeChanged(object sender, EventArgs e)
{
var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
jsonWebView.EvaluateJavaScriptAsync($"setTheme({(isDark ? "true" : "false")})");
}
private void LoadJson(QueryResult queryResult)
{
// Transpose column-oriented dict into a list of row objects
var results = queryResult.Results;
string json;
if (results == null || results.Count == 0)
{
json = "[]";
}
else
{
var columns = results.Keys.ToList();
var rowCount = results[columns[0]].Count;
var rows = Enumerable.Range(0, rowCount)
.Select(i => columns.ToDictionary(c => c, c => results[c][i]))
.ToList();
json = JsonConvert.SerializeObject(rows, Formatting.None);
}
var html = LoadTemplate(json);
jsonWebView.Source = new HtmlWebViewSource { Html = html };
}
private static string LoadTemplate(string json)
{
var assembly = Assembly.GetExecutingAssembly();
const string resourceName = "Xamarin.Neo4j.Visualization.jsonview.html";
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
return $"<html><body>Resource not found: {resourceName}</body></html>";
string html;
using (stream)
using (var reader = new StreamReader(stream))
html = reader.ReadToEnd();
var isDark = Application.Current.RequestedTheme == AppTheme.Dark;
html = html.Replace("{{json}}", json);
html = html.Replace("{{backgroundColor}}", isDark ? "#1e1e1e" : "#ffffff");
html = html.Replace("{{textColor}}", isDark ? "#d4d4d4" : "#1a1a1a");
html = html.Replace("{{toolbarBg}}", isDark ? "#252526" : "#f5f5f5");
html = html.Replace("{{inputBg}}", isDark ? "#3c3c3c" : "#ffffff");
html = html.Replace("{{borderColor}}", isDark ? "#454545" : "#cccccc");
html = html.Replace("{{mutedColor}}", isDark ? "#808080" : "#8a8a8a");
return html;
} }
} }
} }

View File

@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
:root{--bg:{{backgroundColor}};--fg:{{textColor}};--toolbar:{{toolbarBg}};--input:{{inputBg}};--border:{{borderColor}};--muted:{{mutedColor}}}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'Courier New',monospace;font-size:13px;background:var(--bg);color:var(--fg);overscroll-behavior:none}
#toolbar{position:sticky;top:0;z-index:10;background:var(--toolbar);padding:8px;display:flex;flex-direction:column;gap:6px;border-bottom:1px solid var(--border)}
#toolbar input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input);color:var(--fg);font-family:inherit;font-size:13px;outline:none;-webkit-appearance:none}
#toolbar-row2{display:flex;gap:6px;align-items:center}
#toolbar-row2 input{flex:1}
.tbtn{padding:5px 10px;border:1px solid var(--border);border-radius:6px;background:var(--input);color:var(--muted);font-size:11px;cursor:pointer;white-space:nowrap;flex-shrink:0}
.tbtn:active{opacity:.7}
#status{font-size:11px;color:var(--muted);padding:0 2px;min-height:15px}
#output{padding:10px}
.s{color:#4CAF50}.n{color:#64B5F6}.b{color:#FFB74D}.nl{color:#90A4AE}.k{color:#EF9A9A}.e{color:#EF5350}
.br{color:var(--fg);opacity:.7}.cm{color:var(--fg);opacity:.4}
/* Block-based tree nodes */
.node,.knode{display:block}
.nhd{display:block;cursor:default}
.tog{cursor:pointer;user-select:none;color:var(--muted);display:inline-block;width:16px;font-size:11px}
.tog:hover{color:var(--fg)}
.ch{padding-left:18px;border-left:1px dotted var(--border);margin-left:7px}
.node.cls>.ch,.knode.cls>.ch{display:none}
.sum{display:none;color:var(--muted)}
.node.cls .sum,.knode.cls .sum{display:inline}
.row{white-space:pre-wrap;word-break:break-word}
.hl{background:#FFF176;color:#000;border-radius:2px}
</style>
</head>
<body>
<div id="toolbar">
<input id="srch" type="search" placeholder="Search…" />
<div id="toolbar-row2">
<input id="filt" type="search" placeholder="jq: .key .[0] select(.id==1) map(.name) keys" />
<button class="tbtn" onclick="collapseAll(true)">⊟ All</button>
<button class="tbtn" onclick="collapseAll(false)">⊞ All</button>
</div>
<div id="status"></div>
</div>
<div id="output"></div>
<script>
const RAW={{json}};
let cur=RAW,srch='';
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function hl(s){if(!srch)return esc(s);const re=new RegExp('('+srch.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','gi');return esc(s).replace(re,'<span class="hl">$1</span>')}
let uid=0;
// Render a primitive value inline
function rvPrim(v){
if(v===null)return'<span class="nl">null</span>';
if(typeof v==='string')return'<span class="s">"'+hl(v)+'"</span>';
if(typeof v==='number')return'<span class="n">'+hl(v)+'</span>';
if(typeof v==='boolean')return'<span class="b">'+v+'</span>';
return esc(String(v));
}
// Render any value. For objects/arrays returns a block div; for primitives returns inline span.
function rv(v,cm){
const c=cm?'<span class="cm">,</span>':'';
if(v===null||typeof v!=='object')return rvPrim(v)+c;
if(Array.isArray(v))return ra(v,cm);
return ro(v,cm);
}
function ra(a,cm){
const c=cm?'<span class="cm">,</span>':'';
if(!a.length)return'<span class="br">[]</span>'+c;
const id='n'+(uid++);
const items=a.map((v,i)=>rvItem(v,i<a.length-1)).join('');
return'<div class="node" id="'+id+'">'+
'<div class="nhd"><span class="tog" onclick="tog(\''+id+'\')">▾</span><span class="br">[</span><span class="sum"> '+a.length+' ]'+c+'</span></div>'+
'<div class="ch">'+items+'<div class="row"><span class="br">]</span>'+c+'</div></div>'+
'</div>';
}
function ro(o,cm){
const c=cm?'<span class="cm">,</span>':'';
const ks=Object.keys(o);
if(!ks.length)return'<span class="br">{}</span>'+c;
const id='n'+(uid++);
const inner=ks.map((k,i)=>rkv(k,o[k],i<ks.length-1)).join('');
return'<div class="node" id="'+id+'">'+
'<div class="nhd"><span class="tog" onclick="tog(\''+id+'\')">▾</span><span class="br">{</span><span class="sum"> '+ks.length+' }'+c+'</span></div>'+
'<div class="ch">'+inner+'<div class="row"><span class="br">}</span>'+c+'</div></div>'+
'</div>';
}
// Render a key-value pair inside an object. Integrates key+toggle+bracket on one line for object/array values.
function rkv(k,v,hasComma){
const c=hasComma?'<span class="cm">,</span>':'';
if(v!==null&&typeof v==='object'){
const isArr=Array.isArray(v);
const ob=isArr?'[':'{', cb=isArr?']':'}';
const n=isArr?v.length:Object.keys(v).length;
if(!n)return'<div class="row"><span class="k">"'+hl(k)+'"</span><span class="br">: '+ob+cb+'</span>'+c+'</div>';
const id='n'+(uid++);
const children=isArr
?v.map((val,i)=>rvItem(val,i<v.length-1)).join('')
:Object.keys(v).map((sk,i)=>rkv(sk,v[sk],i<Object.keys(v).length-1)).join('');
return'<div class="knode" id="'+id+'">'+
'<div class="row nhd"><span class="k">"'+hl(k)+'"</span><span class="br">: </span><span class="tog" onclick="tog(\''+id+'\')">▾</span><span class="br">'+ob+'</span><span class="sum"> '+n+cb+c+'</span></div>'+
'<div class="ch">'+children+'<div class="row"><span class="br">'+cb+'</span>'+c+'</div></div>'+
'</div>';
}
return'<div class="row"><span class="k">"'+hl(k)+'"</span><span class="br">: </span>'+rvPrim(v)+c+'</div>';
}
// Render an array item (object/array or primitive)
function rvItem(v,hasComma){
const c=hasComma?'<span class="cm">,</span>':'';
if(v!==null&&typeof v==='object'){
const isArr=Array.isArray(v);
const ob=isArr?'[':'{', cb=isArr?']':'}';
const n=isArr?v.length:Object.keys(v).length;
if(!n)return'<div class="row"><span class="br">'+ob+cb+'</span>'+c+'</div>';
const id='n'+(uid++);
const children=isArr
?v.map((val,i)=>rvItem(val,i<v.length-1)).join('')
:Object.keys(v).map((k,i)=>rkv(k,v[k],i<Object.keys(v).length-1)).join('');
return'<div class="knode" id="'+id+'">'+
'<div class="row nhd"><span class="tog" onclick="tog(\''+id+'\')">▾</span><span class="br">'+ob+'</span><span class="sum"> '+n+cb+c+'</span></div>'+
'<div class="ch">'+children+'<div class="row"><span class="br">'+cb+'</span>'+c+'</div></div>'+
'</div>';
}
return'<div class="row">'+rvPrim(v)+c+'</div>';
}
function tog(id){const el=document.getElementById(id);el.classList.toggle('cls');el.querySelector('.tog').textContent=el.classList.contains('cls')?'▸':'▾'}
function collapseAll(collapse){
document.querySelectorAll('.node,.knode').forEach(function(el){
if(collapse){el.classList.add('cls');var t=el.querySelector('.tog');if(t)t.textContent='▸';}
else{el.classList.remove('cls');var t=el.querySelector('.tog');if(t)t.textContent='▾';}
});
}
function status(msg,err){const el=document.getElementById('status');el.textContent=msg;el.style.color=err?'#EF5350':'var(--muted)'}
function render(){
uid=0;
document.getElementById('output').innerHTML='<div class="row">'+rv(cur,false)+'</div>';
status(count(cur)+' values'+(srch?' · "'+srch+'"':''));
}
function count(v){
if(v===null||typeof v!=='object')return 1;
if(Array.isArray(v))return v.reduce((s,i)=>s+count(i),0);
return Object.values(v).reduce((s,i)=>s+count(i),0);
}
function setTheme(isDark){
const r=document.documentElement.style;
r.setProperty('--bg',isDark?'#1e1e1e':'#ffffff');
r.setProperty('--fg',isDark?'#d4d4d4':'#1a1a1a');
r.setProperty('--toolbar',isDark?'#252526':'#f5f5f5');
r.setProperty('--input',isDark?'#3c3c3c':'#ffffff');
r.setProperty('--border',isDark?'#454545':'#cccccc');
r.setProperty('--muted',isDark?'#808080':'#8a8a8a');
document.body.style.background=isDark?'#1e1e1e':'#ffffff';
}
document.getElementById('srch').addEventListener('input',function(){srch=this.value.trim().toLowerCase();render()});
document.getElementById('filt').addEventListener('input',function(){
const ex=this.value.trim();
if(!ex){cur=RAW;render();return}
try{let r=jq(RAW,ex);cur=r===undefined?null:r;render()}
catch(e){status('Error: '+e.message,true)}
});
// ---- jq engine ----
function jq(d,ex){
ex=ex.trim();
if(!ex||ex==='.')return d;
if(ex.includes('|')){const ps=pipes(ex);return ps.reduce((x,p)=>{if(Array.isArray(x))return x.flatMap(i=>{const r=jq(i,p);return Array.isArray(r)?r:[r]});return jq(x,p)},d)}
if(ex==='.')return d;
if(ex==='.[]'){if(Array.isArray(d))return d;if(d&&typeof d==='object')return Object.values(d);throw new Error('.[] on non-iterable')}
if(ex==='keys'){ma(d);return Object.keys(d)}
if(ex==='values'){ma(d);return Object.values(d)}
if(ex==='length')return Array.isArray(d)?d.length:d&&typeof d==='object'?Object.keys(d).length:typeof d==='string'?d.length:null;
if(ex==='type')return Array.isArray(d)?'array':typeof d;
if(ex==='reverse'){mo(d);return[...d].reverse()}
if(ex==='sort'){mo(d);return[...d].sort((a,b)=>a<b?-1:a>b?1:0)}
if(ex==='unique'){mo(d);return[...new Set(d.map(v=>JSON.stringify(v)))].map(s=>JSON.parse(s))}
if(ex==='first'){mo(d);return d[0]}
if(ex==='last'){mo(d);return d[d.length-1]}
if(ex==='flatten'){mo(d);return d.flat(Infinity)}
if(ex==='add'){mo(d);if(!d.length)return null;if(typeof d[0]==='number')return d.reduce((s,v)=>s+v,0);if(typeof d[0]==='string')return d.join('');return null}
if(ex==='any'){mo(d);return d.some(Boolean)}
if(ex==='all'){mo(d);return d.every(Boolean)}
if(ex==='to_entries'){ma(d);return Object.entries(d).map(([k,v])=>({key:k,value:v}))}
if(ex==='from_entries'){mo(d);const o={};for(const e of d)o[e.key||e.name]=e.value;return o}
if(ex==='not')return!d;
let m;
if(m=ex.match(/^\.(\w+)$/))return d?.[m[1]]??null;
if(m=ex.match(/^\.\[(-?\d+)\]$/)){const i=+m[1];return Array.isArray(d)?(i<0?d[d.length+i]:d[i]):null}
if(m=ex.match(/^\.\[(-?\d*):(-?\d*)\]$/)){mo(d);return d.slice(m[1]?+m[1]:0,m[2]?+m[2]:d.length)}
if(m=ex.match(/^\.(\w+)\[\]$/)){const v=d?.[m[1]];return Array.isArray(v)?v:[v]}
if(m=ex.match(/^\.(\w+)\?$/)){try{return d?.[m[1]]??null}catch{return null}}
if(m=ex.match(/^select\((.+)\)$/)){const ok=cond(d,m[1]);return ok?d:undefined}
if(m=ex.match(/^map\((.+)\)$/)){mo(d);return d.map(i=>jq(i,m[1])).filter(v=>v!==undefined)}
if(m=ex.match(/^map_values\((.+)\)$/)){if(Array.isArray(d))return d.map(i=>jq(i,m[1]));if(d&&typeof d==='object'){const o={};for(const k of Object.keys(d))o[k]=jq(d[k],m[1]);return o}}
if(m=ex.match(/^with_entries\((.+)\)$/))return jq(jq(d,'to_entries'),`map(${m[1]})|from_entries`);
if(m=ex.match(/^has\("(\w+)"\)$/))return m[1] in(d||{});
if(m=ex.match(/^has\((\d+)\)$/))return+m[1]<(Array.isArray(d)?d.length:0);
if(m=ex.match(/^group_by\(\.(\w+)\)$/)){mo(d);const g={};for(const i of d){const k=String(i?.[m[1]]);(g[k]=g[k]||[]).push(i)}return Object.values(g)}
if(m=ex.match(/^unique_by\(\.(\w+)\)$/)){mo(d);const s=new Set(),o=[];for(const i of d){const k=JSON.stringify(i?.[m[1]]);if(!s.has(k)){s.add(k);o.push(i)}}return o}
if(m=ex.match(/^sort_by\(\.(\w+)\)$/)){mo(d);const k=m[1];return[...d].sort((a,b)=>a?.[k]<b?.[k]?-1:a?.[k]>b?.[k]?1:0)}
if(m=ex.match(/^min_by\(\.(\w+)\)$/)){mo(d);const k=m[1];return d.reduce((x,v)=>x===null||v?.[k]<x?.[k]?v:x,null)}
if(m=ex.match(/^max_by\(\.(\w+)\)$/)){mo(d);const k=m[1];return d.reduce((x,v)=>x===null||v?.[k]>x?.[k]?v:x,null)}
if(m=ex.match(/^del\(\.(\w+)\)$/)){if(Array.isArray(d))return d.map(i=>{const o={...i};delete o[m[1]];return o});const o={...d};delete o[m[1]];return o}
if(m=ex.match(/^limit\((\d+);(.+)\)$/)){const r=jq(d,m[2]);return Array.isArray(r)?r.slice(0,+m[1]):r}
if(m=ex.match(/^(\.[\w\[\]:,?]+)\.([\w].*)$/))return jq(jq(d,m[1]),'.'+m[2]);
throw new Error('Unknown: '+ex);
}
function cond(d,c){
c=c.trim();
if(c.includes(' and ')){const[a,b]=c.split(' and ',2);return cond(d,a)&&cond(d,b)}
if(c.includes(' or ')){const[a,b]=c.split(' or ',2);return cond(d,a)||cond(d,b)}
if(c.startsWith('not '))return!cond(d,c.slice(4));
let m;
if(m=c.match(/^\.(\w+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/)){
const v=d?.[m[1]];let r;try{r=JSON.parse(m[3])}catch{r=m[3].replace(/^"|"$/g,'')}
if(m[2]==='==')return v===r;if(m[2]==='!=')return v!==r;if(m[2]==='>')return v>r;if(m[2]==='<')return v<r;if(m[2]==='>='||m[2]==='≥')return v>=r;if(m[2]==='<='||m[2]==='≤')return v<=r;
}
if(m=c.match(/^has\("(\w+)"\)$/))return m[1] in(d||{});
if(m=c.match(/^\.(\w+)$/))return!!(d?.[m[1]]);
throw new Error('Unknown condition: '+c);
}
function pipes(ex){const ps=[];let dep=0,cur='';for(let i=0;i<ex.length;i++){if(ex[i]==='(')dep++;else if(ex[i]===')')dep--;else if(ex[i]==='|'&&dep===0){ps.push(cur.trim());cur='';continue}cur+=ex[i]}ps.push(cur.trim());return ps}
function mo(v){if(!Array.isArray(v))throw new Error('Expected array, got '+typeof v)}
function ma(v){if(!v||typeof v!=='object'||Array.isArray(v))throw new Error('Expected object, got '+typeof v)}
render();
</script>
</body>
</html>