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
router.delete('/sources/:id', async (req, res) => {
try {

View File

@ -9,11 +9,15 @@ CREATE TABLE IF NOT EXISTS pf.source (
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,

View File

@ -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)
@ -237,15 +237,23 @@ export default function Forecast({ sourceId, versionId }) {
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
await viewer.restore(cfg)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else {
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
const cfg = {
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 }) {
<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
</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 && (
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
)}