Store stack pivot layouts in DB; drop pivot_layouts FK

pivot_layouts.source_name had a FK to sources(name) preventing stack names
from being used as layout keys. Dropped the FK so any view name works.

- database/migrate_pivot_layouts_drop_fk.sql: drop the FK constraint
- api/routes/stacks.js: add GET/POST/DELETE /:name/layouts routes
- ui/src/api.js: add getStackPivotLayouts / saveStackPivotLayout / deleteStackPivotLayout
- ui/src/pages/Pivot.jsx: use DB for stack layouts instead of localStorage;
  collapse source/stack branches into saveLayout/deleteLayout helpers
- CLAUDE.md: document pivot layout persistence pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-05-02 15:19:58 -04:00
parent bef3d6d89c
commit 1791bf0f0a
5 changed files with 58 additions and 37 deletions

View File

@ -162,6 +162,10 @@ Clicking a data cell opens a right-hand inspector panel showing the underlying t
- The panel is resizable via a drag handle on its left edge (`paneWidth` state, min 240px).
- The transaction table is sortable (click header) and shows column totals for all-numeric columns.
### Pivot layout persistence
Named layouts are stored in `dataflow.pivot_layouts` for both sources and stacks. The `source_name` column holds either a source name or a stack name — the FK to `sources(name)` was dropped to allow this. Source layouts use `/api/sources/:name/layouts`; stack layouts use `/api/stacks/:name/layouts`. Both call the same DB functions (`list_pivot_layouts`, `save_pivot_layout`, `delete_pivot_layout`). `localStorage` is still used to remember the *last active layout* for a view (the `psp_layout_<name>` key), but named layout definitions live in the DB so they persist across machines.
## File Structure
```

View File

@ -170,5 +170,32 @@ module.exports = (pool) => {
} catch (err) { next(err); }
});
// Pivot layouts (same DB table as sources; FK was dropped to allow stack names)
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;
};

View File

@ -0,0 +1,4 @@
-- Drop the foreign key from pivot_layouts.source_name so stack view names can also
-- be used as layout keys (stacks are not rows in the sources table).
ALTER TABLE dataflow.pivot_layouts
DROP CONSTRAINT pivot_layouts_source_name_fkey;

View File

@ -108,11 +108,16 @@ export const api = {
getMappingsByOutputField: (col, val) => request('GET', `/mappings/outputs/${encodeURIComponent(col)}/${encodeURIComponent(val)}`),
remapOutputField: (col, from_val, to_val) => request('POST', '/mappings/remap-field', { col, from_val, to_val }),
// Pivot layouts
// Pivot layouts (sources)
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}`),
// Pivot layouts (stacks)
getStackPivotLayouts: (name) => request('GET', `/stacks/${name}/layouts`),
saveStackPivotLayout: (name, layout_name, config) => request('POST', `/stacks/${name}/layouts`, { layout_name, config }),
deleteStackPivotLayout: (name, id) => request('DELETE', `/stacks/${name}/layouts/${id}`),
// Stacks
getStacks: () => request('GET', '/stacks'),
getStack: (name) => request('GET', `/stacks/${name}`),

View File

@ -127,14 +127,10 @@ export default function Pivot({ source }) {
const loadLayouts = useCallback(async () => {
if (!selectedView) return
try {
if (viewType === 'source') {
const rows = await api.getPivotLayouts(selectedView)
setLayouts(rows)
} else {
// Stacks: localStorage only
const stored = localStorage.getItem(`psp_layouts_stack_${selectedView}`)
setLayouts(stored ? JSON.parse(stored) : [])
}
const rows = viewType === 'source'
? await api.getPivotLayouts(selectedView)
: await api.getStackPivotLayouts(selectedView)
setLayouts(rows)
} catch {}
}, [selectedView, viewType])
@ -318,11 +314,6 @@ export default function Pivot({ source }) {
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(cleaned))
} catch {
// Layout references columns that no longer exist remove it
if (viewType === 'stack') {
const updated = layouts.filter(l => l.id !== layout.id)
setLayouts(updated)
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
}
localStorage.removeItem(LAYOUT_KEY(selectedView))
setActiveLayoutId(null)
await viewer.restore({ table: selectedView, settings: false })
@ -337,19 +328,22 @@ export default function Pivot({ source }) {
return { ...viewerConfig, plugin_config: pluginConfig, expand_depth: expandDepthRef.current }
}
const saveLayout = (name, config) => viewType === 'source'
? api.savePivotLayout(selectedView, name, config)
: api.saveStackPivotLayout(selectedView, name, config)
const deleteLayout = (id) => viewType === 'source'
? api.deletePivotLayout(selectedView, id)
: api.deleteStackPivotLayout(selectedView, id)
async function handleSaveOver() {
const layout = layouts.find(l => l.id === activeLayoutId)
if (!layout) return
const config = await captureConfig()
if (!config) return
try {
if (viewType === 'source') {
const saved = await api.savePivotLayout(selectedView, layout.layout_name, config)
setActiveLayoutId(saved.id)
} else {
const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config } : l)
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
}
const saved = await saveLayout(layout.layout_name, config)
setActiveLayoutId(saved.id)
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
await loadLayouts()
flashMsg('Saved!')
@ -364,18 +358,10 @@ export default function Pivot({ source }) {
const config = await captureConfig()
if (!config) return
try {
let newId
if (viewType === 'source') {
const saved = await api.savePivotLayout(selectedView, name, config)
newId = saved.id
} else {
newId = Date.now()
const updated = [...layouts, { id: newId, layout_name: name, config }]
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
}
const saved = await saveLayout(name, config)
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
await loadLayouts()
setActiveLayoutId(newId)
setActiveLayoutId(saved.id)
setShowSaveAs(false)
setSaveAsName('')
flashMsg('Saved!')
@ -387,12 +373,7 @@ export default function Pivot({ source }) {
async function handleDelete(layout, e) {
e.stopPropagation()
try {
if (viewType === 'source') {
await api.deletePivotLayout(selectedView, layout.id)
} else {
const updated = layouts.filter(l => l.id !== layout.id)
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
}
await deleteLayout(layout.id)
if (activeLayoutId === layout.id) setActiveLayoutId(null)
await loadLayouts()
flashMsg('Deleted')