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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}`),
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user