diff --git a/CLAUDE.md b/CLAUDE.md index 8c65743..6c198e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_` key), but named layout definitions live in the DB so they persist across machines. + ## File Structure ``` diff --git a/api/routes/stacks.js b/api/routes/stacks.js index 6dc8e33..57009a9 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -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; }; diff --git a/database/migrate_pivot_layouts_drop_fk.sql b/database/migrate_pivot_layouts_drop_fk.sql new file mode 100644 index 0000000..6735691 --- /dev/null +++ b/database/migrate_pivot_layouts_drop_fk.sql @@ -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; diff --git a/ui/src/api.js b/ui/src/api.js index a7c39da..0b7e494 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -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}`), diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index cdd824c..be6293f 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -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')