diff --git a/routes/sources.js b/routes/sources.js index 3b79ab6..cc27fa7 100644 --- a/routes/sources.js +++ b/routes/sources.js @@ -222,6 +222,24 @@ module.exports = function(pool) { } }); + // set or clear the default Perspective layout for a source. + // Body: a Perspective view config (group_by, split_by, columns, plugin_config, …). + // Pass null or {} to clear. + router.put('/sources/:id/default-layout', async (req, res) => { + try { + const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null; + const result = await pool.query( + `UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`, + [layout, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' }); + res.json(result.rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } + }); + // deregister a source — does not drop existing forecast tables router.delete('/sources/:id', async (req, res) => { try { diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index 66f3bf5..ea9ed8c 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -4,16 +4,20 @@ CREATE SCHEMA IF NOT EXISTS pf; CREATE TABLE IF NOT EXISTS pf.source ( - id serial PRIMARY KEY, - schema text NOT NULL, - tname text NOT NULL, - label text, - status text NOT NULL DEFAULT 'active', -- active | archived - created_at timestamptz NOT NULL DEFAULT now(), - created_by text, + id serial PRIMARY KEY, + schema text NOT NULL, + tname text NOT NULL, + label text, + status text NOT NULL DEFAULT 'active', -- active | archived + default_layout jsonb, -- Perspective view config used as the per-source default + created_at timestamptz NOT NULL DEFAULT now(), + created_by text, UNIQUE (schema, tname) ); +-- backfill column for existing installs +ALTER TABLE pf.source ADD COLUMN IF NOT EXISTS default_layout jsonb; + CREATE TABLE IF NOT EXISTS pf.col_meta ( id serial PRIMARY KEY, source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 7557f59..3fffda6 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -29,7 +29,7 @@ function cleanLayout(cfg, validCols) { return c } -export default function Forecast({ sourceId, versionId }) { +export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) { const { dark } = useTheme() const [loading, setLoading] = useState(false) const [largeDataset, setLargeDataset] = useState(false) @@ -238,13 +238,21 @@ export default function Forecast({ sourceId, versionId }) { await viewer.restore(cfg) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) } else { - const valueCol = meta.find(c => c.role === 'value')?.cname - const cfg = { - table: tableName, - settings: false, - group_by: ['pf_iter'], - columns: valueCol ? [valueCol] : [], - plugin_config: { edit_mode: 'SELECT_REGION' } + const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout + let cfg + if (sourceDefault && Object.keys(sourceDefault).length > 0) { + cfg = cleanLayout(sourceDefault, validCols) + cfg.table = tableName + cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) } + } else { + const valueCol = meta.find(c => c.role === 'value')?.cname + cfg = { + table: tableName, + settings: false, + group_by: ['pf_iter'], + columns: valueCol ? [valueCol] : [], + plugin_config: { edit_mode: 'SELECT_REGION' } + } } await viewer.restore(cfg) } @@ -320,6 +328,22 @@ export default function Forecast({ sourceId, versionId }) { flash('Saved') } + async function saveAsSourceDefault() { + const cfg = await captureConfig() + if (!cfg) return + const { table, expand_depth, ...rest } = cfg + try { + const res = await fetch(`/api/sources/${sourceId}/default-layout`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rest) + }) + if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return } + if (refreshSources) await refreshSources() + flash('Saved as source default') + } catch (err) { flash(err.message, 'error') } + } + async function handleSaveOver() { const layout = layouts.find(l => l.id === activeLayoutId) if (!layout) return @@ -492,6 +516,11 @@ export default function Forecast({ sourceId, versionId }) { + {activeLayoutId !== null && ( )}