Pivot: save/restore edit mode and expand depth in named layouts

- Default selection mode is now SELECT_REGION
- plugin.save()/restore() used to capture and apply edit mode
- expand_depth tracked in ref and included in layout config
- applyExpandDepth helper restores depth on layout recall and page load
- Save button overwrites active layout in place (no re-typing name)
- captureConfig() helper shared by save-over and save-as flows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-15 08:40:24 -04:00
parent b88795b015
commit 7c07434049

View File

@ -74,12 +74,15 @@ function filterRowsByConfig(allRows, filters) {
} }
const LAYOUT_KEY = (source) => `psp_layout_${source}` const LAYOUT_KEY = (source) => `psp_layout_${source}`
const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_ROW' } const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_REGION' }
export default function Pivot({ source }) { export default function Pivot({ source }) {
const viewerRef = useRef() const viewerRef = useRef()
const workerRef = useRef() const workerRef = useRef()
const tableRef = useRef()
const allRowsRef = useRef([]) const allRowsRef = useRef([])
const expandDepthRef = useRef(null)
const [status, setStatus] = useState('idle') const [status, setStatus] = useState('idle')
const [error, setError] = useState('') const [error, setError] = useState('')
const [inspectedRows, setInspectedRows] = useState(null) const [inspectedRows, setInspectedRows] = useState(null)
@ -135,8 +138,9 @@ export default function Pivot({ source }) {
if (cancelled) { worker.terminate(); return } if (cancelled) { worker.terminate(); return }
workerRef.current = worker workerRef.current = worker
await worker.table(rows, { name: source }) const table = await worker.table(rows, { name: source })
if (cancelled) return if (cancelled) return
tableRef.current = table
const viewer = viewerRef.current const viewer = viewerRef.current
@ -147,18 +151,42 @@ export default function Pivot({ source }) {
const eventFilters = (detail.config || {}).filter || [] const eventFilters = (detail.config || {}).filter || []
const config = await viewer.save() const config = await viewer.save()
setClickDetail({ row, config, column_names, eventFilters }) setClickDetail({ row, config, column_names, eventFilters })
const matched = filterRowsByConfig(allRowsRef.current, eventFilters)
setInspectedRows(matched) // Use a Perspective view with the event filters + expressions so computed
// columns (split_by) are evaluated and filtered correctly
try {
const view = await tableRef.current.view({
filter: eventFilters,
expressions: config.expressions || [],
})
const data = await view.to_json()
await view.delete()
// Strip expression columns only show raw source columns
const exprNames = new Set(Object.keys(config.expressions || {}))
const cleaned = data.map(r =>
Object.fromEntries(Object.entries(r).filter(([k]) => !exprNames.has(k)))
)
setInspectedRows(cleaned)
} catch {
setInspectedRows(filterRowsByConfig(allRowsRef.current, eventFilters))
}
}) })
await viewer.load(worker) await viewer.load(worker)
const plugin = await viewer.getPlugin()
const savedLayout = localStorage.getItem(LAYOUT_KEY(source)) const savedLayout = localStorage.getItem(LAYOUT_KEY(source))
if (savedLayout) { if (savedLayout) {
await viewer.restore(JSON.parse(savedLayout)) const parsed = JSON.parse(savedLayout)
await viewer.restore(parsed)
await plugin.restore(parsed.plugin_config || DEFAULT_PLUGIN_CONFIG)
if (parsed.expand_depth != null) await applyExpandDepth(viewer, parsed.expand_depth)
} else { } else {
await viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) await viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
await plugin.restore(DEFAULT_PLUGIN_CONFIG)
} }
await viewer.flush()
setStatus('ready') setStatus('ready')
} catch (err) { } catch (err) {
if (!cancelled) { setStatus('error'); setError(err.message) } if (!cancelled) { setStatus('error'); setError(err.message) }
@ -169,21 +197,58 @@ export default function Pivot({ source }) {
return () => { cancelled = true } return () => { cancelled = true }
}, [source]) }, [source])
async function applyExpandDepth(viewer, depth) {
if (depth == null) return
const view = await viewer.getView()
await view.set_depth(depth)
const plugin = await viewer.getPlugin()
await plugin.draw(view)
expandDepthRef.current = depth
}
async function applyLayout(layout) { async function applyLayout(layout) {
const viewer = viewerRef.current const viewer = viewerRef.current
if (!viewer) return if (!viewer) return
await viewer.restore(layout.config) await viewer.restore(layout.config)
if (layout.config.plugin_config) {
const plugin = await viewer.getPlugin()
await plugin.restore(layout.config.plugin_config)
}
await applyExpandDepth(viewer, layout.config.expand_depth ?? null)
setActiveLayoutId(layout.id) setActiveLayoutId(layout.id)
// also persist to localStorage so it survives refresh // also persist to localStorage so it survives refresh
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config)) localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config))
} }
async function captureConfig() {
const viewer = viewerRef.current
if (!viewer) return null
const plugin = await viewer.getPlugin()
const [viewerConfig, pluginConfig] = await Promise.all([viewer.save(), plugin.save()])
return { ...viewerConfig, plugin_config: pluginConfig, expand_depth: expandDepthRef.current }
}
async function handleSaveOver() {
const layout = layouts.find(l => l.id === activeLayoutId)
if (!layout) return
const config = await captureConfig()
if (!config) return
try {
const saved = await api.savePivotLayout(source, layout.layout_name, config)
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config))
await loadLayouts()
setActiveLayoutId(saved.id)
flashMsg('Saved!')
} catch (err) {
flashMsg(err.message)
}
}
async function handleSaveAs() { async function handleSaveAs() {
const name = saveAsName.trim() const name = saveAsName.trim()
if (!name) return if (!name) return
const viewer = viewerRef.current const config = await captureConfig()
if (!viewer) return if (!config) return
const config = await viewer.save()
try { try {
const saved = await api.savePivotLayout(source, name, config) const saved = await api.savePivotLayout(source, name, config)
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config)) localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config))
@ -257,6 +322,13 @@ export default function Pivot({ source }) {
</div> </div>
))} ))}
{activeLayoutId !== null && !showSaveAs && (
<button onClick={handleSaveOver}
className="text-xs text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5">
Save
</button>
)}
{showSaveAs ? ( {showSaveAs ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
@ -296,7 +368,8 @@ export default function Pivot({ source }) {
await view.set_depth(d) await view.set_depth(d)
const p = await v.getPlugin() const p = await v.getPlugin()
await p.draw(view) await p.draw(view)
}} className="text-xs border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400 hover:border-gray-400"> expandDepthRef.current = d
}} className="text-xs border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400">
{d} {d}
</button> </button>
))} ))}