Pivot: named layouts saved in DB per source

- pivot_layouts table (source_name, layout_name, config JSONB)
- list/save/delete SQL functions and API routes
- Pivot toolbar above viewer: layout chips, save-as inline input,
  delete per layout, reset to default
- Applying a named layout also updates localStorage working state
- Layouts reload on source change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-15 07:31:46 -04:00
parent 23fa14f22c
commit 3723778cbb
5 changed files with 277 additions and 130 deletions

View File

@ -230,5 +230,32 @@ module.exports = (pool) => {
} }
}); });
// Pivot layouts
router.get('/:name/layouts', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM list_pivot_layouts(${lit(req.params.name)})`);
res.json(result.rows);
} catch (err) { next(err); }
});
router.post('/:name/layouts', async (req, res, next) => {
try {
const { layout_name, config } = req.body;
if (!layout_name || !config) return res.status(400).json({ error: 'layout_name and config required' });
const result = await pool.query(
`SELECT * FROM save_pivot_layout(${lit(req.params.name)}, ${lit(layout_name)}, ${lit(config)})`
);
res.json(result.rows[0]);
} catch (err) { next(err); }
});
router.delete('/:name/layouts/:id', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM delete_pivot_layout(${lit(parseInt(req.params.id))})`);
if (result.rows.length === 0) return res.status(404).json({ error: 'Layout not found' });
res.json({ success: true });
} catch (err) { next(err); }
});
return router; return router;
}; };

View File

@ -196,3 +196,28 @@ BEGIN
RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql); RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql);
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- List saved pivot layouts for a source
CREATE OR REPLACE FUNCTION list_pivot_layouts(p_source_name TEXT)
RETURNS TABLE(id INT, source_name TEXT, layout_name TEXT, config JSONB, created_at TIMESTAMPTZ) AS $$
SELECT id, source_name, layout_name, config, created_at
FROM dataflow.pivot_layouts
WHERE source_name = p_source_name
ORDER BY layout_name;
$$ LANGUAGE sql;
-- Save (upsert) a named pivot layout
CREATE OR REPLACE FUNCTION save_pivot_layout(p_source_name TEXT, p_layout_name TEXT, p_config JSONB)
RETURNS TABLE(id INT, source_name TEXT, layout_name TEXT, config JSONB, created_at TIMESTAMPTZ) AS $$
INSERT INTO dataflow.pivot_layouts (source_name, layout_name, config)
VALUES (p_source_name, p_layout_name, p_config)
ON CONFLICT (source_name, layout_name) DO UPDATE
SET config = EXCLUDED.config
RETURNING id, source_name, layout_name, config, created_at;
$$ LANGUAGE sql;
-- Delete a named pivot layout
CREATE OR REPLACE FUNCTION delete_pivot_layout(p_id INT)
RETURNS TABLE(id INT) AS $$
DELETE FROM dataflow.pivot_layouts WHERE id = p_id RETURNING id;
$$ LANGUAGE sql;

View File

@ -140,10 +140,21 @@ CREATE INDEX idx_import_log_source ON import_log(source_name);
CREATE INDEX idx_import_log_timestamp ON import_log(imported_at); CREATE INDEX idx_import_log_timestamp ON import_log(imported_at);
CREATE TABLE pivot_layouts (
id SERIAL PRIMARY KEY,
source_name TEXT NOT NULL REFERENCES sources(name) ON DELETE CASCADE,
layout_name TEXT NOT NULL,
config JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_name, layout_name)
);
CREATE INDEX idx_pivot_layouts_source ON pivot_layouts(source_name);
------------------------------------------------------ ------------------------------------------------------
-- Summary -- Summary
------------------------------------------------------ ------------------------------------------------------
-- Tables: 5 (sources, records, rules, mappings, import_log) -- Tables: 6 (sources, records, rules, mappings, import_log, pivot_layouts)
-- Simple, clear structure -- Simple, clear structure
-- JSONB for flexibility -- JSONB for flexibility
-- Deduplication via hash key -- Deduplication via hash key

View File

@ -103,6 +103,11 @@ export const api = {
updateMapping: (id, body) => request('PUT', `/mappings/${id}`, body), updateMapping: (id, body) => request('PUT', `/mappings/${id}`, body),
deleteMapping: (id) => request('DELETE', `/mappings/${id}`), deleteMapping: (id) => request('DELETE', `/mappings/${id}`),
// Pivot layouts
getPivotLayouts: (source) => request('GET', `/sources/${source}/layouts`),
savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }),
deletePivotLayout: (source, id) => request('DELETE', `/sources/${source}/layouts/${id}`),
// Records // Records
getRecords: (source, limit = 100, offset = 0) => getRecords: (source, limit = 100, offset = 0) =>
request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`),

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { api } from '../api' import { api } from '../api'
async function fetchAllRows(source) { async function fetchAllRows(source) {
@ -32,7 +32,6 @@ function loadPerspective() {
function formatVal(v) { function formatVal(v) {
if (v == null) return null if (v == null) return null
// Perspective returns dates as ms timestamps
if (typeof v === 'number' && v > 1e11 && v < 2e12) { if (typeof v === 'number' && v > 1e11 && v < 2e12) {
const d = new Date(v) const d = new Date(v)
if (!isNaN(d)) return d.toISOString().slice(0, 10) if (!isNaN(d)) return d.toISOString().slice(0, 10)
@ -42,15 +41,10 @@ function formatVal(v) {
function normalize(v) { function normalize(v) {
if (v == null) return null 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) if (typeof v === 'number' && v > 1e11 && v < 2e12) return new Date(v).toISOString().slice(0, 10)
return String(v).trim() return String(v).trim()
} }
// Apply perspective-click event filters directly to raw rows.
// Each filter is [field, operator, value] same format Perspective uses internally.
// Filters for fields that don't exist in the raw data (e.g. Perspective computed columns)
// are skipped they can't be matched against the source rows.
function filterRowsByConfig(allRows, filters) { function filterRowsByConfig(allRows, filters) {
if (!filters || filters.length === 0) return allRows if (!filters || filters.length === 0) return allRows
const knownFields = allRows.length > 0 ? new Set(Object.keys(allRows[0])) : new Set() const knownFields = allRows.length > 0 ? new Set(Object.keys(allRows[0])) : new Set()
@ -88,18 +82,40 @@ export default function Pivot({ source }) {
const allRowsRef = 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 [inspectedRows, setInspectedRows] = useState(null) const [inspectedRows, setInspectedRows] = useState(null)
const [clickDetail, setClickDetail] = useState(null) const [clickDetail, setClickDetail] = useState(null)
// Named layouts
const [layouts, setLayouts] = useState([])
const [activeLayoutId, setActiveLayoutId] = useState(null)
const [saveAsName, setSaveAsName] = useState('')
const [showSaveAs, setShowSaveAs] = useState(false)
const [layoutMsg, setLayoutMsg] = useState('')
const flashMsg = (msg) => {
setLayoutMsg(msg)
setTimeout(() => setLayoutMsg(''), 2000)
}
const loadLayouts = useCallback(async () => {
if (!source) return
try {
const rows = await api.getPivotLayouts(source)
setLayouts(rows)
} catch {}
}, [source])
useEffect(() => { useEffect(() => {
if (!source) return if (!source) return
let cancelled = false let cancelled = false
setSaved(false)
setInspectedRows(null) setInspectedRows(null)
setClickDetail(null) setClickDetail(null)
setActiveLayoutId(null)
setShowSaveAs(false)
allRowsRef.current = [] allRowsRef.current = []
loadLayouts()
async function init() { async function init() {
setStatus('loading') setStatus('loading')
setError('') setError('')
@ -128,8 +144,6 @@ export default function Pivot({ source }) {
const detail = e.detail || {} const detail = e.detail || {}
const { row, column_names } = detail const { row, column_names } = detail
if (!row) return if (!row) return
// detail.config has the cell-specific filters (group_by + split_by values + user filters)
// viewer.save() gives us the full config including group_by/split_by field names
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 })
@ -155,27 +169,58 @@ export default function Pivot({ source }) {
return () => { cancelled = true } return () => { cancelled = true }
}, [source]) }, [source])
async function saveLayout() { async function applyLayout(layout) {
const viewer = viewerRef.current const viewer = viewerRef.current
if (!viewer) return if (!viewer) return
const layout = await viewer.save() await viewer.restore(layout.config)
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout)) setActiveLayoutId(layout.id)
setSaved(true) // also persist to localStorage so it survives refresh
setTimeout(() => setSaved(false), 2000) localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config))
} }
function clearLayout() { async function handleSaveAs() {
localStorage.removeItem(LAYOUT_KEY(source)) const name = saveAsName.trim()
if (!name) return
const viewer = viewerRef.current const viewer = viewerRef.current
if (viewer) viewer.restore({ table: source, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) if (!viewer) return
const config = await viewer.save()
try {
const saved = await api.savePivotLayout(source, name, config)
localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(config))
await loadLayouts()
setActiveLayoutId(saved.id)
setShowSaveAs(false)
setSaveAsName('')
flashMsg('Saved!')
} catch (err) {
flashMsg(err.message)
}
}
async function handleDelete(layout, e) {
e.stopPropagation()
try {
await api.deletePivotLayout(source, layout.id)
if (activeLayoutId === layout.id) setActiveLayoutId(null)
await loadLayouts()
flashMsg('Deleted')
} catch (err) {
flashMsg(err.message)
}
}
function handleResetToDefault() {
const viewer = viewerRef.current
if (!viewer) return
localStorage.removeItem(LAYOUT_KEY(source))
setActiveLayoutId(null)
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]) : [] const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
// Extract cell coordinates from event filters for display.
// group_by and split_by values appear as "==" filters in the event config.
const groupBy = clickDetail?.config?.group_by || [] const groupBy = clickDetail?.config?.group_by || []
const splitBy = clickDetail?.config?.split_by || [] const splitBy = clickDetail?.config?.split_by || []
const coordFields = new Set([...groupBy, ...splitBy]) const coordFields = new Set([...groupBy, ...splitBy])
@ -185,133 +230,167 @@ export default function Pivot({ source }) {
.map(([f, , v]) => [f, v]) .map(([f, , v]) => [f, v])
) )
const cellCoords = [...groupBy, ...splitBy].map(f => coordMap[f]).filter(Boolean) const cellCoords = [...groupBy, ...splitBy].map(f => coordMap[f]).filter(Boolean)
// Build the specific clicked cell's column key (e.g. "2025-01-01|08 August|Amount")
// so we can show just that value rather than all non-null metric columns.
const splitVals = splitBy.map(f => coordMap[f]).filter(Boolean) const splitVals = splitBy.map(f => coordMap[f]).filter(Boolean)
const metrics = clickDetail?.column_names || [] const metrics = clickDetail?.column_names || []
const cellKey = splitVals.length > 0 && metrics.length > 0 const cellKey = splitVals.length > 0 && metrics.length > 0
? [...splitVals, ...metrics].join('|') ? [...splitVals, ...metrics].join('|')
: null : null
const cellValue = cellKey != null ? clickDetail?.row?.[cellKey] : null
return ( return (
<div className="relative w-full h-full flex"> <div className="w-full h-full flex flex-col">
<div className="relative flex-1">
{status === 'ready' && ( {/* Layout toolbar */}
<div className="absolute top-2 left-3 z-20 flex gap-2"> <div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0">
<button onClick={saveLayout} <span className="text-xs text-gray-400 uppercase tracking-wide mr-1">Layouts</span>
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'} {layouts.map(l => (
</button> <div key={l.id}
{localStorage.getItem(LAYOUT_KEY(source)) && ( onClick={() => applyLayout(l)}
<button onClick={clearLayout} className={`flex items-center gap-1 text-xs rounded px-2 py-0.5 cursor-pointer border transition-colors
className="text-xs bg-white border border-gray-200 rounded px-2 py-1 text-gray-400 hover:text-red-500 shadow-sm"> ${activeLayoutId === l.id
Reset ? 'bg-blue-50 border-blue-300 text-blue-700'
</button> : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}>
)} {l.layout_name}
<button
onClick={(e) => handleDelete(l, e)}
className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button>
</div> </div>
)} ))}
{status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50"> {showSaveAs ? (
<p className="text-sm text-gray-400">Loading</p> <div className="flex items-center gap-1">
<input
autoFocus
value={saveAsName}
onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…"
className="text-xs border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400"
/>
<button onClick={handleSaveAs} className="text-xs text-blue-600 hover:text-blue-800 px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-xs text-gray-400 hover:text-gray-600 px-1">Cancel</button>
</div> </div>
) : (
<button
onClick={() => setShowSaveAs(true)}
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
+ Save as
</button>
)} )}
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50"> {activeLayoutId !== null && (
<p className="text-sm text-red-500">Error: {error}</p> <button onClick={handleResetToDefault}
</div> className="text-xs text-gray-300 hover:text-gray-500 ml-1">
reset
</button>
)} )}
{status === 'noview' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50"> {layoutMsg && <span className="text-xs text-green-600 ml-1">{layoutMsg}</span>}
<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>
{inspectedRows && clickDetail && ( {/* Pivot + inspector */}
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"> <div className="relative flex-1 flex min-h-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100"> <div className="relative flex-1">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide"> {status === 'loading' && (
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} <div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
</span> <p className="text-sm text-gray-400">Loading</p>
<button onClick={() => { setInspectedRows(null); setClickDetail(null) }} </div>
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button> )}
</div> {status === 'error' && (
<div className="flex-1 overflow-y-auto"> <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>
{/* Cell coordinates */} {inspectedRows && clickDetail && (
<div className="px-3 py-2 border-b border-gray-100"> <div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1"> <div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
{[...groupBy, ...splitBy].join(' ') || clickDetail.column_names?.join(', ') || 'Cell'} <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">
{/* Cell coordinates */}
<div className="px-3 py-2 border-b border-gray-100">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
{[...groupBy, ...splitBy].join(' ') || clickDetail.column_names?.join(', ') || 'Cell'}
</div>
{cellCoords.length > 0 && (
<div className="text-xs text-gray-700 font-mono font-semibold">
{cellCoords.join(' ')}
</div>
)}
{Object.entries(clickDetail.row)
.filter(([k, v]) => k !== '__ROW_PATH__' && v != null)
.map(([k, v]) => {
const isSelected = cellKey != null && k === cellKey
return (
<div key={k} className={`flex justify-between py-0.5 gap-2 ${isSelected ? 'font-semibold' : ''}`}>
<span className={`text-xs font-mono shrink-0 ${isSelected ? 'text-gray-700' : 'text-gray-400'}`}>{k}</span>
<span className={`text-xs font-mono text-right ${isSelected ? 'text-blue-600' : 'text-gray-700'}`}>{formatVal(v)}</span>
</div>
)
})}
</div> </div>
{cellCoords.length > 0 && (
<div className="text-xs text-gray-700 font-mono font-semibold"> {/* User-set filters */}
{cellCoords.join(' ')} {(() => {
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
return userFilters.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">Filters</div>
{userFilters.map((f, i) => (
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
))}
</div>
) : null
})()}
{/* 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>
)} )}
{/* All non-null metrics for the row; highlight the specific clicked cell if known */}
{Object.entries(clickDetail.row)
.filter(([k, v]) => k !== '__ROW_PATH__' && v != null)
.map(([k, v]) => {
const isSelected = cellKey != null && k === cellKey
return (
<div key={k} className={`flex justify-between py-0.5 gap-2 ${isSelected ? 'font-semibold' : ''}`}>
<span className={`text-xs font-mono shrink-0 ${isSelected ? 'text-gray-700' : 'text-gray-400'}`}>{k}</span>
<span className={`text-xs font-mono text-right ${isSelected ? 'text-blue-600' : 'text-gray-700'}`}>{formatVal(v)}</span>
</div>
)
})}
</div> </div>
{/* User-set filters (exclude cell-coordinate filters) */}
{(() => {
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
return userFilters.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">Filters</div>
{userFilters.map((f, i) => (
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div>
))}
</div>
) : null
})()}
{/* 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> )}
)} </div>
</div> </div>
) )
} }