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:
parent
23fa14f22c
commit
3723778cbb
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}`),
|
||||
|
||||
@ -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 <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||||
|
||||
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,33 +230,67 @@ 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 (
|
||||
<div className="relative w-full h-full flex">
|
||||
<div className="relative flex-1">
|
||||
{status === 'ready' && (
|
||||
<div className="absolute top-2 left-3 z-20 flex gap-2">
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
<div className="w-full h-full flex flex-col">
|
||||
|
||||
{/* Layout toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white border-b border-gray-200 flex-shrink-0">
|
||||
<span className="text-xs text-gray-400 uppercase tracking-wide mr-1">Layouts</span>
|
||||
|
||||
{layouts.map(l => (
|
||||
<div key={l.id}
|
||||
onClick={() => 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}
|
||||
<button
|
||||
onClick={(e) => handleDelete(l, e)}
|
||||
className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button>
|
||||
</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' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50">
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
@ -254,7 +333,6 @@ export default function Pivot({ source }) {
|
||||
{cellCoords.join(' › ')}
|
||||
</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]) => {
|
||||
@ -268,7 +346,7 @@ export default function Pivot({ source }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* User-set filters (exclude cell-coordinate filters) */}
|
||||
{/* User-set filters */}
|
||||
{(() => {
|
||||
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
|
||||
return userFilters.length > 0 ? (
|
||||
@ -313,5 +391,6 @@ export default function Pivot({ source }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user