Add per-source default Perspective layout

Forecast falls back to a saved per-source layout when no version-local
layout is cached, so new versions of a source open with a sensible pivot
without each user reconfiguring it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-29 22:31:23 -04:00
parent 953ae2709f
commit 39335bca75
3 changed files with 66 additions and 15 deletions

View File

@ -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 // deregister a source — does not drop existing forecast tables
router.delete('/sources/:id', async (req, res) => { router.delete('/sources/:id', async (req, res) => {
try { try {

View File

@ -4,16 +4,20 @@
CREATE SCHEMA IF NOT EXISTS pf; CREATE SCHEMA IF NOT EXISTS pf;
CREATE TABLE IF NOT EXISTS pf.source ( CREATE TABLE IF NOT EXISTS pf.source (
id serial PRIMARY KEY, id serial PRIMARY KEY,
schema text NOT NULL, schema text NOT NULL,
tname text NOT NULL, tname text NOT NULL,
label text, label text,
status text NOT NULL DEFAULT 'active', -- active | archived status text NOT NULL DEFAULT 'active', -- active | archived
created_at timestamptz NOT NULL DEFAULT now(), default_layout jsonb, -- Perspective view config used as the per-source default
created_by text, created_at timestamptz NOT NULL DEFAULT now(),
created_by text,
UNIQUE (schema, tname) 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 ( CREATE TABLE IF NOT EXISTS pf.col_meta (
id serial PRIMARY KEY, id serial PRIMARY KEY,
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,

View File

@ -29,7 +29,7 @@ function cleanLayout(cfg, validCols) {
return c return c
} }
export default function Forecast({ sourceId, versionId }) { export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
const { dark } = useTheme() const { dark } = useTheme()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false) const [largeDataset, setLargeDataset] = useState(false)
@ -238,13 +238,21 @@ export default function Forecast({ sourceId, versionId }) {
await viewer.restore(cfg) await viewer.restore(cfg)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else { } else {
const valueCol = meta.find(c => c.role === 'value')?.cname const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
const cfg = { let cfg
table: tableName, if (sourceDefault && Object.keys(sourceDefault).length > 0) {
settings: false, cfg = cleanLayout(sourceDefault, validCols)
group_by: ['pf_iter'], cfg.table = tableName
columns: valueCol ? [valueCol] : [], cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
plugin_config: { edit_mode: 'SELECT_REGION' } } 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) await viewer.restore(cfg)
} }
@ -320,6 +328,22 @@ export default function Forecast({ sourceId, versionId }) {
flash('Saved') 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() { 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
@ -492,6 +516,11 @@ export default function Forecast({ sourceId, versionId }) {
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5"> <button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
Save as Save as
</button> </button>
<button onClick={saveAsSourceDefault} disabled={!sourceId}
className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
title="Use this layout as the default for new versions of this source">
Set source default
</button>
{activeLayoutId !== null && ( {activeLayoutId !== null && (
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button> <button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
)} )}