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:
parent
bef3d6d89c
commit
1791bf0f0a
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
4
database/migrate_pivot_layouts_drop_fk.sql
Normal file
4
database/migrate_pivot_layouts_drop_fk.sql
Normal 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;
|
||||
@ -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}`),
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user