Pivot: row select default, click inspector with underlying rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-14 22:03:58 -04:00
parent ebd88a2df8
commit e3ceb70fc6

View File

@ -1,19 +1,16 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../api' import { api } from '../api'
// Fetch all rows for a source (no pagination limit)
async function fetchAllRows(source) { async function fetchAllRows(source) {
const res = await api.getViewData(source, 100000, 0) const res = await api.getViewData(source, 100000, 0)
return res.rows || [] return res.rows || []
} }
let perspectiveLoaded = false
let perspectivePromise = null let perspectivePromise = null
function loadPerspective() { function loadPerspective() {
if (perspectivePromise) return perspectivePromise if (perspectivePromise) return perspectivePromise
perspectivePromise = (async () => { perspectivePromise = (async () => {
// Inject theme CSS once
if (!document.getElementById('psp-theme')) { if (!document.getElementById('psp-theme')) {
const link = document.createElement('link') const link = document.createElement('link')
link.id = 'psp-theme' link.id = 'psp-theme'
@ -28,45 +25,84 @@ function loadPerspective() {
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'), import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.4.0/dist/cdn/perspective-viewer-datagrid.js'),
import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'), import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.4.0/dist/cdn/perspective-viewer-d3fc.js'),
]) ])
perspectiveLoaded = true
return perspective return perspective
})() })()
return perspectivePromise return perspectivePromise
} }
function formatVal(v) {
if (v == null) return null
// Perspective returns dates as ms timestamps
if (typeof v === 'number' && v > 1e11 && v < 2e12) {
const d = new Date(v)
if (!isNaN(d)) return d.toISOString().slice(0, 10)
}
return String(v)
}
function normalize(v) {
if (v == null) return null
// Perspective returns dates as ms timestamps convert to ISO date string
if (typeof v === 'number' && v > 1e11 && v < 2e12) return new Date(v).toISOString().slice(0, 10)
return String(v).trim()
}
function filterRows(allRows, row, config) {
const groupBy = config.group_by || []
const splitBy = config.split_by || []
const pivotCols = [...groupBy, ...splitBy]
if (pivotCols.length === 0) {
// Flat view the clicked row IS one underlying row; return it directly
// (no need to re-filter; Perspective already shows one row per record)
return [row]
}
// Pivoted filter allRows by each group/split column value
const criteria = pivotCols
.map(col => ({ col, val: normalize(row[col]) }))
.filter(({ val }) => val != null)
return allRows.filter(r =>
criteria.every(({ col, val }) => normalize(r[col]) === val)
)
}
const LAYOUT_KEY = (source) => `psp_layout_${source}` const LAYOUT_KEY = (source) => `psp_layout_${source}`
const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_ROW' }
export default function Pivot({ source }) { export default function Pivot({ source }) {
const viewerRef = useRef() const viewerRef = useRef()
const workerRef = useRef() const workerRef = useRef()
const allRowsRef = useRef([])
const [status, setStatus] = useState('idle') const [status, setStatus] = useState('idle')
const [error, setError] = useState('') const [error, setError] = useState('')
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [inspectedRows, setInspectedRows] = useState(null)
const [clickDetail, setClickDetail] = useState(null)
useEffect(() => { useEffect(() => {
if (!source) return if (!source) return
let cancelled = false let cancelled = false
setSaved(false) setSaved(false)
setInspectedRows(null)
setClickDetail(null)
allRowsRef.current = []
async function init() { async function init() {
setStatus('loading') setStatus('loading')
setError('') setError('')
try { try {
const [perspective, rows] = await Promise.all([ const [perspective, rows] = await Promise.all([
loadPerspective(), loadPerspective(),
fetchAllRows(source), fetchAllRows(source),
]) ])
if (cancelled) return if (cancelled) return
if (!rows.length) { setStatus('noview'); return }
if (!rows.length) { allRowsRef.current = rows
setStatus('noview')
return
}
if (workerRef.current) { if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
try { workerRef.current.terminate() } catch {}
}
const worker = await perspective.worker() const worker = await perspective.worker()
if (cancelled) { worker.terminate(); return } if (cancelled) { worker.terminate(); return }
@ -76,13 +112,23 @@ export default function Pivot({ source }) {
if (cancelled) return if (cancelled) return
const viewer = viewerRef.current const viewer = viewerRef.current
viewer.addEventListener('perspective-click', (e) => {
const detail = e.detail || {}
const { row, config, column_names } = detail
if (!row) return
setClickDetail({ row, config, column_names })
const matched = filterRows(allRowsRef.current, row, config || {})
setInspectedRows(matched.length > 0 ? matched : [row])
})
await viewer.load(worker) await viewer.load(worker)
const saved = localStorage.getItem(LAYOUT_KEY(source)) const savedLayout = localStorage.getItem(LAYOUT_KEY(source))
if (saved) { if (savedLayout) {
await viewer.restore(JSON.parse(saved)) await viewer.restore(JSON.parse(savedLayout))
} else { } else {
await viewer.restore({ table: source, settings: true }) await viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
} }
setStatus('ready') setStatus('ready')
} catch (err) { } catch (err) {
@ -106,50 +152,122 @@ export default function Pivot({ source }) {
function clearLayout() { function clearLayout() {
localStorage.removeItem(LAYOUT_KEY(source)) localStorage.removeItem(LAYOUT_KEY(source))
const viewer = viewerRef.current const viewer = viewerRef.current
if (viewer) viewer.restore({ table: source, settings: true }) if (viewer) viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
} }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full flex">
{status === 'ready' && ( <div className="relative flex-1">
<div className="absolute top-2 right-3 z-20 flex gap-2"> {status === 'ready' && (
<button <div className="absolute top-2 right-3 z-20 flex gap-2">
onClick={saveLayout} <button onClick={saveLayout}
className="text-xs bg-white border border-gray-200 rounded px-2 py-1 text-gray-500 hover:border-gray-400 shadow-sm" className="text-xs bg-white border border-gray-200 rounded px-2 py-1 text-gray-500 hover:border-gray-400 shadow-sm">
> {saved ? 'Saved!' : 'Save layout'}
{saved ? 'Saved!' : 'Save layout'}
</button>
{localStorage.getItem(LAYOUT_KEY(source)) && (
<button
onClick={clearLayout}
className="text-xs bg-white border border-gray-200 rounded px-2 py-1 text-gray-400 hover:text-red-500 shadow-sm"
>
Reset
</button> </button>
)} {localStorage.getItem(LAYOUT_KEY(source)) && (
<button onClick={clearLayout}
className="text-xs bg-white border border-gray-200 rounded px-2 py-1 text-gray-400 hover:text-red-500 shadow-sm">
Reset
</button>
)}
</div>
)}
{status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">Loading</p>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-red-500">Error: {error}</p>
</div>
)}
{status === 'noview' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">No view data generate a view and transform records first.</p>
</div>
)}
<perspective-viewer
ref={viewerRef}
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
/>
</div>
{inspectedRows && clickDetail && (
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
</span>
<button onClick={() => { setInspectedRows(null); setClickDetail(null) }}
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Click context — row values from Perspective */}
<div className="px-3 py-2 border-b border-gray-100">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
{clickDetail.column_names?.join(', ') || 'Cell'}
</div>
{Object.entries(clickDetail.row).map(([k, v]) => (
<div key={k} className="flex justify-between py-0.5 gap-2">
<span className="text-xs text-gray-400 font-mono shrink-0">{k}</span>
<span className="text-xs text-gray-700 font-mono text-right truncate">{formatVal(v) ?? '—'}</span>
</div>
))}
</div>
{/* Config context — group_by / split_by / filters if any */}
{(clickDetail.config?.group_by?.length > 0 || clickDetail.config?.split_by?.length > 0 || clickDetail.config?.filter?.length > 0) && (
<div className="px-3 py-2 border-b border-gray-100">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Pivot context</div>
{clickDetail.config.group_by?.length > 0 && (
<div className="text-xs text-gray-500 py-0.5">group by: <span className="font-mono text-gray-700">{clickDetail.config.group_by.join(', ')}</span></div>
)}
{clickDetail.config.split_by?.length > 0 && (
<div className="text-xs text-gray-500 py-0.5">split by: <span className="font-mono text-gray-700">{clickDetail.config.split_by.join(', ')}</span></div>
)}
{clickDetail.config.filter?.map((f, i) => (
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
))}
</div>
)}
{/* Underlying rows */}
{inspectedRows.length > 0 && (
<div className="overflow-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0">
{cols.map(c => (
<th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{inspectedRows.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
{cols.map(c => {
const f = formatVal(row[c])
return (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-gray-700 max-w-40 truncate">
{f == null ? <span className="text-gray-300"></span> : f}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div> </div>
)} )}
{status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">Loading</p>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-red-500">Error: {error}</p>
</div>
)}
{status === 'noview' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">No view data generate a view and transform records first.</p>
</div>
)}
<perspective-viewer
ref={viewerRef}
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
/>
</div> </div>
) )
} }