From 3723778cbbd5361a750fb1d80486cde38d01099e Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 15 Apr 2026 07:31:46 -0400 Subject: [PATCH] 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 --- api/routes/sources.js | 27 +++ database/queries/sources.sql | 25 +++ database/schema.sql | 13 +- ui/src/api.js | 5 + ui/src/pages/Pivot.jsx | 337 +++++++++++++++++++++-------------- 5 files changed, 277 insertions(+), 130 deletions(-) diff --git a/api/routes/sources.js b/api/routes/sources.js index 587c56e..271ad2c 100644 --- a/api/routes/sources.js +++ b/api/routes/sources.js @@ -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; }; diff --git a/database/queries/sources.sql b/database/queries/sources.sql index f357a58..7b2cefd 100644 --- a/database/queries/sources.sql +++ b/database/queries/sources.sql @@ -196,3 +196,28 @@ BEGIN RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql); END; $$ 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; diff --git a/database/schema.sql b/database/schema.sql index 3b97619..43ac633 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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 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 ------------------------------------------------------ --- Tables: 5 (sources, records, rules, mappings, import_log) +-- Tables: 6 (sources, records, rules, mappings, import_log, pivot_layouts) -- Simple, clear structure -- JSONB for flexibility -- Deduplication via hash key diff --git a/ui/src/api.js b/ui/src/api.js index 708b5fc..f8bb359 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -103,6 +103,11 @@ export const api = { updateMapping: (id, body) => request('PUT', `/mappings/${id}`, body), 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 getRecords: (source, limit = 100, offset = 0) => request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index 28d1ef3..137b39c 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import { api } from '../api' async function fetchAllRows(source) { @@ -32,7 +32,6 @@ function loadPerspective() { 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) @@ -42,15 +41,10 @@ function formatVal(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() } -// 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) { if (!filters || filters.length === 0) return allRows 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 [status, setStatus] = useState('idle') const [error, setError] = useState('') - const [saved, setSaved] = useState(false) const [inspectedRows, setInspectedRows] = 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(() => { if (!source) return let cancelled = false - setSaved(false) setInspectedRows(null) setClickDetail(null) + setActiveLayoutId(null) + setShowSaveAs(false) allRowsRef.current = [] + loadLayouts() + async function init() { setStatus('loading') setError('') @@ -128,8 +144,6 @@ export default function Pivot({ source }) { const detail = e.detail || {} const { row, column_names } = detail 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 config = await viewer.save() setClickDetail({ row, config, column_names, eventFilters }) @@ -155,27 +169,58 @@ export default function Pivot({ source }) { return () => { cancelled = true } }, [source]) - async function saveLayout() { + async function applyLayout(layout) { const viewer = viewerRef.current if (!viewer) return - const layout = await viewer.save() - localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout)) - setSaved(true) - setTimeout(() => setSaved(false), 2000) + await viewer.restore(layout.config) + setActiveLayoutId(layout.id) + // also persist to localStorage so it survives refresh + localStorage.setItem(LAYOUT_KEY(source), JSON.stringify(layout.config)) } - function clearLayout() { - localStorage.removeItem(LAYOUT_KEY(source)) + async function handleSaveAs() { + const name = saveAsName.trim() + if (!name) return 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
Select a source first.
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 splitBy = clickDetail?.config?.split_by || [] const coordFields = new Set([...groupBy, ...splitBy]) @@ -185,133 +230,167 @@ export default function Pivot({ source }) { .map(([f, , v]) => [f, v]) ) 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 metrics = clickDetail?.column_names || [] const cellKey = splitVals.length > 0 && metrics.length > 0 ? [...splitVals, ...metrics].join('|') : null - const cellValue = cellKey != null ? clickDetail?.row?.[cellKey] : null return ( -
-
- {status === 'ready' && ( -
- - {localStorage.getItem(LAYOUT_KEY(source)) && ( - - )} +
+ + {/* Layout toolbar */} +
+ Layouts + + {layouts.map(l => ( +
applyLayout(l)} + className={`flex items-center gap-1 text-xs rounded px-2 py-0.5 cursor-pointer border transition-colors + ${activeLayoutId === l.id + ? 'bg-blue-50 border-blue-300 text-blue-700' + : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}> + {l.layout_name} +
- )} - {status === 'loading' && ( -
-

Loading…

+ ))} + + {showSaveAs ? ( +
+ 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" + /> + +
+ ) : ( + )} - {status === 'error' && ( -
-

Error: {error}

-
+ + {activeLayoutId !== null && ( + )} - {status === 'noview' && ( -
-

No view data — generate a view and transform records first.

-
- )} - + + {layoutMsg && {layoutMsg}}
- {inspectedRows && clickDetail && ( -
-
- - {inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} - - -
-
+ {/* Pivot + inspector */} +
+
+ {status === 'loading' && ( +
+

Loading…

+
+ )} + {status === 'error' && ( +
+

Error: {error}

+
+ )} + {status === 'noview' && ( +
+

No view data — generate a view and transform records first.

+
+ )} + +
- {/* Cell coordinates */} -
-
- {[...groupBy, ...splitBy].join(' › ') || clickDetail.column_names?.join(', ') || 'Cell'} + {inspectedRows && clickDetail && ( +
+
+ + {inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} + + +
+
+ + {/* Cell coordinates */} +
+
+ {[...groupBy, ...splitBy].join(' › ') || clickDetail.column_names?.join(', ') || 'Cell'} +
+ {cellCoords.length > 0 && ( +
+ {cellCoords.join(' › ')} +
+ )} + {Object.entries(clickDetail.row) + .filter(([k, v]) => k !== '__ROW_PATH__' && v != null) + .map(([k, v]) => { + const isSelected = cellKey != null && k === cellKey + return ( +
+ {k} + {formatVal(v)} +
+ ) + })}
- {cellCoords.length > 0 && ( -
- {cellCoords.join(' › ')} + + {/* User-set filters */} + {(() => { + const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f)) + return userFilters.length > 0 ? ( +
+
Filters
+ {userFilters.map((f, i) => ( +
{f.join(' ')}
+ ))} +
+ ) : null + })()} + + {/* Underlying rows */} + {inspectedRows.length > 0 && ( +
+ + + + {cols.map(c => ( + + ))} + + + + {inspectedRows.map((row, i) => ( + + {cols.map(c => { + const f = formatVal(row[c]) + return ( + + ) + })} + + ))} + +
{c}
+ {f == null ? : f} +
)} - {/* 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 ( -
- {k} - {formatVal(v)} -
- ) - })}
- - {/* User-set filters (exclude cell-coordinate filters) */} - {(() => { - const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f)) - return userFilters.length > 0 ? ( -
-
Filters
- {userFilters.map((f, i) => ( -
{f.join(' ')}
- ))} -
- ) : null - })()} - - {/* Underlying rows */} - {inspectedRows.length > 0 && ( -
- - - - {cols.map(c => ( - - ))} - - - - {inspectedRows.map((row, i) => ( - - {cols.map(c => { - const f = formatVal(row[c]) - return ( - - ) - })} - - ))} - -
{c}
- {f == null ? : f} -
-
- )}
-
- )} + )} +
) }