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,33 +230,67 @@ 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>
))}
{showSaveAs ? (
<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>
) : (
<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>
)} )}
{activeLayoutId !== null && (
<button onClick={handleResetToDefault}
className="text-xs text-gray-300 hover:text-gray-500 ml-1">
reset
</button>
)}
{layoutMsg && <span className="text-xs text-green-600 ml-1">{layoutMsg}</span>}
</div>
{/* Pivot + inspector */}
<div className="relative flex-1 flex min-h-0">
<div className="relative flex-1">
{status === 'loading' && ( {status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50"> <div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
<p className="text-sm text-gray-400">Loading</p> <p className="text-sm text-gray-400">Loading</p>
@ -254,7 +333,6 @@ export default function Pivot({ source }) {
{cellCoords.join(' ')} {cellCoords.join(' ')}
</div> </div>
)} )}
{/* All non-null metrics for the row; highlight the specific clicked cell if known */}
{Object.entries(clickDetail.row) {Object.entries(clickDetail.row)
.filter(([k, v]) => k !== '__ROW_PATH__' && v != null) .filter(([k, v]) => k !== '__ROW_PATH__' && v != null)
.map(([k, v]) => { .map(([k, v]) => {
@ -268,7 +346,7 @@ export default function Pivot({ source }) {
})} })}
</div> </div>
{/* User-set filters (exclude cell-coordinate filters) */} {/* User-set filters */}
{(() => { {(() => {
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f)) const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
return userFilters.length > 0 ? ( return userFilters.length > 0 ? (
@ -313,5 +391,6 @@ export default function Pivot({ source }) {
</div> </div>
)} )}
</div> </div>
</div>
) )
} }