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:
parent
953ae2709f
commit
39335bca75
@ -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 {
|
||||||
|
|||||||
@ -9,11 +9,15 @@ CREATE TABLE IF NOT EXISTS pf.source (
|
|||||||
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
|
||||||
|
default_layout jsonb, -- Perspective view config used as the per-source default
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
created_by text,
|
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,
|
||||||
|
|||||||
@ -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)
|
||||||
@ -237,15 +237,23 @@ export default function Forecast({ sourceId, versionId }) {
|
|||||||
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
|
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
|
||||||
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 {
|
||||||
|
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 {
|
} else {
|
||||||
const valueCol = meta.find(c => c.role === 'value')?.cname
|
const valueCol = meta.find(c => c.role === 'value')?.cname
|
||||||
const cfg = {
|
cfg = {
|
||||||
table: tableName,
|
table: tableName,
|
||||||
settings: false,
|
settings: false,
|
||||||
group_by: ['pf_iter'],
|
group_by: ['pf_iter'],
|
||||||
columns: valueCol ? [valueCol] : [],
|
columns: valueCol ? [valueCol] : [],
|
||||||
plugin_config: { edit_mode: 'SELECT_REGION' }
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user