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 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.
|
- 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
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -170,5 +170,32 @@ module.exports = (pool) => {
|
|||||||
} catch (err) { next(err); }
|
} 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;
|
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)}`),
|
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 }),
|
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`),
|
getPivotLayouts: (source) => request('GET', `/sources/${source}/layouts`),
|
||||||
savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }),
|
savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }),
|
||||||
deletePivotLayout: (source, id) => request('DELETE', `/sources/${source}/layouts/${id}`),
|
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
|
// Stacks
|
||||||
getStacks: () => request('GET', '/stacks'),
|
getStacks: () => request('GET', '/stacks'),
|
||||||
getStack: (name) => request('GET', `/stacks/${name}`),
|
getStack: (name) => request('GET', `/stacks/${name}`),
|
||||||
|
|||||||
@ -127,14 +127,10 @@ export default function Pivot({ source }) {
|
|||||||
const loadLayouts = useCallback(async () => {
|
const loadLayouts = useCallback(async () => {
|
||||||
if (!selectedView) return
|
if (!selectedView) return
|
||||||
try {
|
try {
|
||||||
if (viewType === 'source') {
|
const rows = viewType === 'source'
|
||||||
const rows = await api.getPivotLayouts(selectedView)
|
? await api.getPivotLayouts(selectedView)
|
||||||
setLayouts(rows)
|
: await api.getStackPivotLayouts(selectedView)
|
||||||
} else {
|
setLayouts(rows)
|
||||||
// Stacks: localStorage only
|
|
||||||
const stored = localStorage.getItem(`psp_layouts_stack_${selectedView}`)
|
|
||||||
setLayouts(stored ? JSON.parse(stored) : [])
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [selectedView, viewType])
|
}, [selectedView, viewType])
|
||||||
|
|
||||||
@ -318,11 +314,6 @@ export default function Pivot({ source }) {
|
|||||||
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(cleaned))
|
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(cleaned))
|
||||||
} catch {
|
} catch {
|
||||||
// Layout references columns that no longer exist — remove it
|
// 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))
|
localStorage.removeItem(LAYOUT_KEY(selectedView))
|
||||||
setActiveLayoutId(null)
|
setActiveLayoutId(null)
|
||||||
await viewer.restore({ table: selectedView, settings: false })
|
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 }
|
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() {
|
async function handleSaveOver() {
|
||||||
const layout = layouts.find(l => l.id === activeLayoutId)
|
const layout = layouts.find(l => l.id === activeLayoutId)
|
||||||
if (!layout) return
|
if (!layout) return
|
||||||
const config = await captureConfig()
|
const config = await captureConfig()
|
||||||
if (!config) return
|
if (!config) return
|
||||||
try {
|
try {
|
||||||
if (viewType === 'source') {
|
const saved = await saveLayout(layout.layout_name, config)
|
||||||
const saved = await api.savePivotLayout(selectedView, layout.layout_name, config)
|
setActiveLayoutId(saved.id)
|
||||||
setActiveLayoutId(saved.id)
|
|
||||||
} else {
|
|
||||||
const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config } : l)
|
|
||||||
localStorage.setItem(`psp_layouts_stack_${selectedView}`, JSON.stringify(updated))
|
|
||||||
}
|
|
||||||
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
|
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
|
||||||
await loadLayouts()
|
await loadLayouts()
|
||||||
flashMsg('Saved!')
|
flashMsg('Saved!')
|
||||||
@ -364,18 +358,10 @@ export default function Pivot({ source }) {
|
|||||||
const config = await captureConfig()
|
const config = await captureConfig()
|
||||||
if (!config) return
|
if (!config) return
|
||||||
try {
|
try {
|
||||||
let newId
|
const saved = await saveLayout(name, config)
|
||||||
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))
|
|
||||||
}
|
|
||||||
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
|
localStorage.setItem(LAYOUT_KEY(selectedView), JSON.stringify(config))
|
||||||
await loadLayouts()
|
await loadLayouts()
|
||||||
setActiveLayoutId(newId)
|
setActiveLayoutId(saved.id)
|
||||||
setShowSaveAs(false)
|
setShowSaveAs(false)
|
||||||
setSaveAsName('')
|
setSaveAsName('')
|
||||||
flashMsg('Saved!')
|
flashMsg('Saved!')
|
||||||
@ -387,12 +373,7 @@ export default function Pivot({ source }) {
|
|||||||
async function handleDelete(layout, e) {
|
async function handleDelete(layout, e) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
try {
|
try {
|
||||||
if (viewType === 'source') {
|
await deleteLayout(layout.id)
|
||||||
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))
|
|
||||||
}
|
|
||||||
if (activeLayoutId === layout.id) setActiveLayoutId(null)
|
if (activeLayoutId === layout.id) setActiveLayoutId(null)
|
||||||
await loadLayouts()
|
await loadLayouts()
|
||||||
flashMsg('Deleted')
|
flashMsg('Deleted')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user