Merge branch 'nav-forecast-list'

Forecasts are chosen from the sidebar rather than a status-bar dropdown,
the source is inferred from the one picked, and the Setup and Baseline
pages are admin-only to write and read-only to everyone else -- enforced
by requireAdmin on the routes, not just greyed out in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 01:57:30 -04:00
commit 3426f3525b
13 changed files with 271 additions and 141 deletions

View File

@ -3,7 +3,7 @@ const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
const { sessionUser, sessionTerritory } = require('../lib/auth');
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
@ -572,7 +572,7 @@ module.exports = function(pool) {
});
// load baseline rows from source table — additive, no delete
router.post('/versions/:id/baseline', async (req, res) => {
router.post('/versions/:id/baseline', requireAdmin, async (req, res) => {
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
@ -611,7 +611,7 @@ module.exports = function(pool) {
// edit a baseline or reference segment in place — only allowed before any
// scale/recode/clone has been applied on this version, since those would
// have been calibrated against the old segment's totals.
router.put('/versions/:id/baseline/:logid', async (req, res) => {
router.put('/versions/:id/baseline/:logid', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
const logid = parseInt(req.params.logid);
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
@ -698,7 +698,7 @@ module.exports = function(pool) {
});
// delete all baseline rows and log entries for a version
router.delete('/versions/:id/baseline', async (req, res) => {
router.delete('/versions/:id/baseline', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
try {
const ctx = await getContext(versionId, 'baseline');
@ -732,7 +732,7 @@ module.exports = function(pool) {
});
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
router.post('/versions/:id/reference', requireAdmin, async (req, res) => {
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';

View File

@ -1,5 +1,5 @@
const express = require('express');
const { sessionUser } = require('../lib/auth');
const { sessionUser, requireAdmin } = require('../lib/auth');
// Planned load segments — a version's definition, before it has been run.
//
@ -41,7 +41,7 @@ module.exports = function(pool) {
}
});
router.post('/versions/:id/plans', async (req, res) => {
router.post('/versions/:id/plans', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
const b = req.body || {};
if (!['baseline', 'reference'].includes(b.kind)) {
@ -73,7 +73,7 @@ module.exports = function(pool) {
// Any subset of the fields, plus run_logid when a run has just happened.
// The flag-and-value pair rather than COALESCE on the value: clearing a
// label to blank has to be distinguishable from not mentioning it.
router.patch('/plans/:pid', async (req, res) => {
router.patch('/plans/:pid', requireAdmin, async (req, res) => {
const pid = parseInt(req.params.pid);
const b = req.body || {};
const keys = [...FIELDS, 'run_logid'].filter(k => b[k] !== undefined);
@ -109,7 +109,7 @@ module.exports = function(pool) {
}
});
router.delete('/plans/:pid', async (req, res) => {
router.delete('/plans/:pid', requireAdmin, async (req, res) => {
try {
const { rows } = await pool.query(
`DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)]

View File

@ -1,7 +1,7 @@
const express = require('express');
const { generateSQL, buildTerritoryClause } = require('../lib/sql_generator');
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
const { sessionTerritory } = require('../lib/auth');
const { sessionTerritory, requireAdmin } = require('../lib/auth');
const { sessionUser } = require('../lib/auth');
module.exports = function(pool) {
@ -22,7 +22,7 @@ module.exports = function(pool) {
// register a source table
// auto-populates col_meta from information_schema with role='ignore'
router.post('/sources', async (req, res) => {
router.post('/sources', requireAdmin, async (req, res) => {
const { schema, tname, label } = req.body;
const created_by = sessionUser(req);
if (!schema || !tname) {
@ -81,7 +81,7 @@ module.exports = function(pool) {
});
// save col_meta — accepts full array, upserts each row
router.put('/sources/:id/cols', async (req, res) => {
router.put('/sources/:id/cols', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
const cols = req.body;
if (!Array.isArray(cols)) {
@ -142,7 +142,7 @@ module.exports = function(pool) {
});
// generate SQL for all operations from current col_meta and store in pf.sql
router.post('/sources/:id/generate-sql', async (req, res) => {
router.post('/sources/:id/generate-sql', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
try {
const srcResult = await pool.query(
@ -338,7 +338,7 @@ module.exports = function(pool) {
// Slow by nature -- it reads the whole source, which for a view over a
// transaction table is millions of rows -- so it is a deliberate action rather
// than something that happens on a page load.
router.post('/sources/:id/dim/:group/refresh', async (req, res) => {
router.post('/sources/:id/dim/:group/refresh', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
const group = req.params.group;
try {
@ -473,7 +473,7 @@ module.exports = function(pool) {
}
});
router.delete('/sources/:id', async (req, res) => {
router.delete('/sources/:id', requireAdmin, async (req, res) => {
try {
const result = await pool.query(
`DELETE FROM pf.source WHERE id = $1 RETURNING *`,

View File

@ -1,6 +1,9 @@
const express = require('express');
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
// Reads, deliberately open to everyone: Setup is visible read-only to
// non-admins, so gating what the page needs in order to render turned it into
// a 403 the client rendered as a crash. The writes it leads to are gated.
module.exports = function(pool) {
const router = express.Router();

View File

@ -1,6 +1,6 @@
const express = require('express');
const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils');
const { sessionUser, sessionTerritory } = require('../lib/auth');
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
const { buildTerritoryClause } = require('../lib/sql_generator');
// Copy a version's definition onto a freshly created one: its load segments as
@ -90,7 +90,7 @@ module.exports = function(pool) {
// create a new version
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
router.post('/sources/:id/versions', async (req, res) => {
router.post('/sources/:id/versions', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
const { name, description, exclude_iters, clone_from } = req.body;
const created_by = sessionUser(req);
@ -514,7 +514,7 @@ ${colDefs},
// The three name columns are flag-and-value pairs rather than COALESCE:
// clearing one back to the built-in means writing null, which COALESCE on
// the value alone cannot tell from "not mentioned".
router.put('/versions/:id', async (req, res) => {
router.put('/versions/:id', requireAdmin, async (req, res) => {
const { name, description, exclude_iters,
adjustment_segment, adjustment_bucket, unlabeled_load,
is_default } = req.body;
@ -595,7 +595,7 @@ ${colDefs},
});
// close a version — blocks further edits
router.post('/versions/:id/close', async (req, res) => {
router.post('/versions/:id/close', requireAdmin, async (req, res) => {
const pf_user = sessionUser(req);
try {
const result = await pool.query(`
@ -615,7 +615,7 @@ ${colDefs},
});
// reopen a closed version
router.post('/versions/:id/reopen', async (req, res) => {
router.post('/versions/:id/reopen', requireAdmin, async (req, res) => {
try {
const result = await pool.query(`
UPDATE pf.version
@ -635,7 +635,7 @@ ${colDefs},
// delete a version — drops forecast table then deletes version record
// log entries are removed by ON DELETE CASCADE on pf.log.version_id
router.delete('/versions/:id', async (req, res) => {
router.delete('/versions/:id', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
const client = await pool.connect();
try {

View File

@ -39,8 +39,35 @@ export default function App() {
return list
}, [sourceId])
// Every source's forecasts, for the sidebar only.
//
// `versions` stays scoped to the selected source, because Baseline and
// Forecast are, and widening it would change what it means everywhere. The
// sidebar is the one place that has to be cross-source: it is how a forecast
// gets chosen, and a forecast carries its own source_id, so choosing one
// settles the source. Without this, a second registered source would be
// unreachable from anywhere but Setup -- and invisible from everywhere else,
// which is worse.
//
// A fan-out rather than an endpoint: there is one source today, so this is one
// request, and a `GET /versions` is not worth adding until there are enough to
// make the difference measurable.
const [allVersions, setAllVersions] = useState([])
const refreshAllVersions = useCallback(async (list) => {
const srcs = list ?? sources
if (!srcs.length) { setAllVersions([]); return }
const lists = await Promise.all(srcs.map(s =>
fetch(`/api/sources/${s.id}/versions`).then(r => r.json()).catch(() => [])))
setAllVersions(lists.flat().filter(v => v && v.id != null))
}, [sources])
useEffect(() => { refreshSources() }, [])
// Recomputed when the selected source's list changes, which is what creating
// or deleting a version does -- so the sidebar follows without every caller
// having to know it exists.
useEffect(() => { if (sourcesLoaded) refreshAllVersions() }, [sourcesLoaded, sources, versions])
// The selection is restored from localStorage and survives a deregister, so it
// has to be re-checked against the list itself rather than only at mount:
// deleting the selected source otherwise leaves a dead id behind and every
@ -90,7 +117,12 @@ export default function App() {
return (
<div className="flex h-screen w-full text-sm overflow-hidden">
<Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<Sidebar
view={view} setView={setView}
expanded={sidebarExpanded} setExpanded={setSidebarExpanded}
versions={allVersions} versionId={versionId} setVersionId={setVersionId}
sourceId={sourceId} setSourceId={setSourceId}
/>
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
<StatusBar view={view} {...ctx} />
<div className="flex-1 overflow-hidden">

View File

@ -0,0 +1,22 @@
// Says why everything on the page is greyed out.
//
// The disabling itself is one `<fieldset disabled>` around the page rather than
// a flag threaded through every control -- Baseline alone has forty-five of
// them, and the one that got missed would be the one that mattered. A disabled
// fieldset takes its descendant form controls out with it natively, so nothing
// has to remember to opt in.
//
// None of this is the permission. The routes carry requireAdmin; this only
// stops an account being offered a button that would answer 403.
export default function ReadOnlyNotice({ what }) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 border-b border-amber-200 text-amber-800 text-xs shrink-0">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" className="shrink-0">
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
Read-only — {what} is managed by an administrator.
</div>
)
}

View File

@ -32,7 +32,41 @@ const NAV = [
},
]
export default function Sidebar({ view, setView, expanded, setExpanded }) {
// The forecasts are listed here, above the steps, rather than offered as a
// dropdown in the status bar. Two reasons: a scenario is a thing you open, not
// a setting you change, and the status bar is otherwise read-only, so a control
// sitting in it did not read as one.
//
// Not nested under the Forecast button, which is where this started: Baseline
// works on the same version, so hanging it off one step would either
// misrepresent the scope or duplicate the list under two. It reads
// "what am I working on" and then "what step am I on".
//
// The source is deliberately absent. `gs.osm_skinny` means nothing to someone
// editing a forecast, and a version already carries its source, so choosing a
// forecast settles it. Picking the source is a Setup concern and the status bar
// only offers it there.
export default function Sidebar({
view, setView, expanded, setExpanded,
versions = [], versionId, setVersionId,
sourceId, setSourceId,
}) {
const selected = versions.find(v => String(v.id) === String(versionId))
function pick(v) {
// The source comes with the forecast. This is the whole reason the source
// needs no control of its own outside Setup: every version carries its
// source_id, so choosing one settles both. Set first, so the version is not
// briefly validated against the outgoing source's list.
if (v.source_id != null && String(v.source_id) !== String(sourceId)) {
setSourceId(String(v.source_id))
}
setVersionId(String(v.id))
// Setup is the one step a version means nothing to, so choosing one there
// is a statement of intent to go and work on it.
if (view === 'setup') setView('forecast')
}
return (
<div
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
@ -58,6 +92,61 @@ export default function Sidebar({ view, setView, expanded, setExpanded }) {
</span>
</div>
{expanded ? (
<div className="px-2 pt-2 pb-1.5 border-b border-gray-100 shrink-0">
<div className="px-2 pb-1 text-gray-400 uppercase tracking-wide" style={{ fontSize: '10px' }}>
Forecasts
</div>
{/* Capped and scrollable: the list grows with the work, and the steps
below it must never be pushed off the bottom. */}
<div className="max-h-52 overflow-y-auto">
{versions.length === 0 && (
<div className="px-2 py-1 text-xs text-gray-400">none yet</div>
)}
{versions.map(v => {
const active = String(v.id) === String(versionId)
return (
<button
key={v.id}
onClick={() => pick(v)}
title={`${v.name}${v.is_default ? ' · default' : ''} · ${v.status}`}
className={`w-full flex items-center gap-1.5 rounded px-2 py-1 text-left transition-colors ${
active ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${active ? 'bg-blue-600' : 'bg-transparent'}`} />
<span className="text-xs truncate flex-1">{v.name}</span>
{v.is_default && (
<span className="text-amber-500 shrink-0" style={{ fontSize: '10px' }} title="Default">★</span>
)}
<span
className={`shrink-0 ${v.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}
style={{ fontSize: '10px' }}
>
{v.status}
</span>
</button>
)
})}
</div>
</div>
) : (
// Collapsed, the list has nowhere to go, so all that survives is which
// one is open — as a tooltip, on a button that opens the rail again.
<div className="p-2 border-b border-gray-100 shrink-0">
<button
onClick={() => setExpanded(true)}
title={selected ? `Forecast: ${selected.name}` : 'Forecasts'}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400"
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 3h8l4 4v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/>
<polyline points="12,3 12,7 16,7"/>
</svg>
</button>
</div>
)}
<nav className="flex flex-col gap-0.5 p-2 flex-1">
{NAV.map(item => {
const active = view === item.id

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import useTheme from '../theme.jsx'
import useAuth from '../auth.jsx'
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId }) {
const { dark, setDark } = useTheme()
const { user, logout } = useAuth()
const showVersion = view === 'baseline' || view === 'forecast'
@ -52,55 +52,44 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
return (
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs relative">
<span className="text-gray-400">Source</span>
<select
value={sourceId || ''}
onChange={e => setSourceId(e.target.value)}
disabled={sources.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
>
{sources.length === 0
? <option value="">— no sources —</option>
: sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
{/* Source is a Setup concern: the raw table name means nothing to someone
editing a forecast, and a version carries its own source, so picking a
forecast in the sidebar settles it everywhere else. */}
{view === 'setup' && (
<>
<span className="text-gray-400">Source</span>
<select
value={sourceId || ''}
onChange={e => setSourceId(e.target.value)}
disabled={sources.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
>
{sources.length === 0
? <option value="">— no sources —</option>
: sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
</>
)}
{showVersion && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Version</span>
<select
value={versionId || ''}
onChange={e => setVersionId(e.target.value)}
disabled={versions.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
>
{versions.length === 0
? <option value="">— no versions —</option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
</select>
{selectedVersion && (
<span className={`font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion.status}
</span>
)}
{/* write target — the physical table every operation appends to */}
{/* What is open, and how big it is -- the two things a forecaster
wants from a status bar. The physical table it writes to is an
implementation detail and means nothing to a sales account, so it
has moved into the hover panel and onto the Baseline page, which
is where a version's plumbing is managed. */}
{info && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400" title="Operations append to this table">writes to</span>
<button
onClick={copyTable}
<span
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
className={`font-mono px-1.5 py-0.5 rounded border hover:bg-gray-50 ${
info.exists ? 'text-gray-700 border-gray-200' : 'text-amber-700 border-amber-200 bg-amber-50'
}`}
title={info.exists ? 'Click to copy table name' : 'Table does not exist yet'}
className="font-medium text-gray-700 cursor-default"
title="Hover for the write target and row breakdown"
>
{copied ? 'copied!' : info.fc_table}
</button>
<span className="text-gray-400 font-mono">
{selectedVersion?.name || '—'}
</span>
<span className={`font-mono ${info.exists ? 'text-gray-400' : 'text-amber-700'}`}>
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
</span>
@ -109,7 +98,15 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
<div className="text-gray-400 uppercase tracking-wide mb-2" style={{ fontSize: '10px' }}>Write target</div>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<span className="text-gray-400">table</span>
<span className="font-mono text-gray-700">{info.fc_table}</span>
{/* Still copyable, just no longer the headline: pasting it
into psql is the reason it was ever on screen. */}
<button
onClick={copyTable}
title="Click to copy table name"
className="font-mono text-gray-700 text-left hover:text-blue-700"
>
{copied ? 'copied!' : info.fc_table}
</button>
<span className="text-gray-400">reads from</span>
<span className="font-mono text-gray-700">{info.source}</span>
<span className="text-gray-400">total rows</span>

View File

@ -42,14 +42,31 @@ body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary
--text-muted, so changing the utility would have fixed light mode only. At
10px/gray-400 these sat near 2.5:1 on white and 2:1 on the dark panel, under
the 4.5:1 WCAG wants for text this size. */
/* Names the group of controls beside it — Layout, Rows, Columns. Weighted and
tracked so it reads as a label rather than as another small grey control:
the toolbar is dense, and at the old weight the labels and the buttons they
name were the same visual object. */
.pf-section-label {
font-size: 11px;
font-size: 10px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.06em;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6b7280;
color: #4b5563;
white-space: nowrap;
}
.dark .pf-section-label { color: #9aa0aa; }
.dark .pf-section-label { color: #c5c9d0; }
/* The rule between groups inside a zone. Taller and lighter than the old
`w-px h-4 bg-gray-200`, which was the same weight as the zone borders, so
the groups within a zone and the zones themselves separated identically. */
.pf-group-rule {
width: 1px;
height: 20px;
flex-shrink: 0;
background: #b6bcc5;
}
.dark .pf-group-rule { background: #5a616b; }
.dark .text-gray-300 { color: var(--text-muted); }
.dark .text-gray-400 { color: var(--text-muted); }

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import Timeline from '../components/Timeline.jsx'
import useAuth from '../auth.jsx'
import ReadOnlyNotice from '../components/ReadOnlyNotice.jsx'
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
@ -88,6 +89,7 @@ function normalizeFilters(stored) {
export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) {
const { user: me } = useAuth()
const isAdmin = !!me?.is_admin
const [filterCols, setFilterCols] = useState([])
const [log, setLog] = useState([])
@ -605,6 +607,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
which for the table is the measured column widths below. */}
<div className="p-4 flex flex-col gap-4 items-start max-w-full">
{!isAdmin && <ReadOnlyNotice what="segments and forecast settings" />}
<fieldset disabled={!isAdmin} className="contents">
{msg && (
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
{msg.text}
@ -695,6 +701,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</div>
)}
</fieldset>
{/* Tags. Not a table anywhere -- a tag is a value repeated across this
version's pf.log entries, and the bridge groups on it. So renaming
one here rewrites every entry that carries it, and renaming onto an
@ -775,6 +783,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</div>
)}
<fieldset disabled={!isAdmin} className="contents">
{showNewVersion && (
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
<div className="flex items-end gap-3">
@ -1063,6 +1073,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</>}
</fieldset>
</div>
</div>
)

View File

@ -1213,60 +1213,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
table.draw()
}
// Size every column to its contents.
//
// Values fit on their own: draw() calls regular_table.resetAutoSize(), which
// clears the width caches so the next draw measures cells and sets each
// column's min-width from the result. The complication is that the plugin's own
// draw saves the *live* widths first and restores them immediately after the
// reset, so going through it preserves whatever the columns already are. Hence
// driving regular_table directly.
//
// Two things that measurement will never fix:
//
// - Group headers are excluded on purpose. pro.css says so:
// /* Header groups should overflow and not contribute to auto-sizing. */
// thead tr:not(.rt-autosize) th { overflow: hidden; max-width: 0px; }
// Only the leaf header row participates. Letting a group label set width
// would widen every column beneath it, so this is left alone -- with the
// ordinal prefix in front, "01 - Prior…" still reads when truncated.
//
// - The row-label column measures its *header*, which for row headers is a
// blank corner cell, so it never reflects the labels underneath. That one
// is worth fixing, and is what fitRowLabels does below.
async function fitColumns() {
const viewer = viewerRef.current
if (!viewer) return
try {
const plugin = await viewer.getPlugin()
const grid = plugin?.regular_table
if (!grid?.resetAutoSize) {
flash('This plugin does not support fitting columns', 'error')
return
}
// _cached_column_sizes is consumed by the next save_column_size_overrides
// and would otherwise be restored over the measurement
plugin._cached_column_sizes = undefined
plugin._reset_column_size = false
grid.resetAutoSize()
await grid.draw({ invalid_columns: true })
// Row labels are deliberately left at their default width. Each group_by
// level is its own column, so fitting the first to its longest label pushes
// the second to start after it, and the default spacing reads better than
// the fitted one. fitRowLabels below still does it, one call away, if that
// judgement changes.
// Widths are not part of ViewConfig, so persist the layout to keep the
// saved copy in step with what is on screen.
const cfg = await captureConfig()
if (cfg) await persistLayout(versionId, cfg)
} catch (err) {
console.error('[fitColumns]', err)
flash(err.message || String(err), 'error')
}
}
// A floor under the row-label columns, not a fit.
//
// Restoring a layout resets the widths, and the row-header columns are then
@ -2095,8 +2041,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
buttons separated by identical rules, so "Fit" and "Bridge"
read as peers when one is a column width and the other opens
the version's history. ── */}
<div className="flex items-center gap-2.5 flex-wrap bg-gray-50 border border-gray-100
rounded px-2 py-1">
<div className="flex items-center gap-3 flex-wrap bg-gray-50 border border-gray-200
rounded-md px-2.5 py-1">
<LayoutMenu
layouts={layouts}
@ -2120,7 +2066,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
offered levels that did not exist and hid ones that did. */}
{groupFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
<div className="pf-group-rule" />
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
onPick={applyDepth} totalLabel="Total" />
</>
@ -2128,26 +2074,19 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{splitFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
<div className="pf-group-rule" />
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
onPick={applySplitDepth} totalLabel="Total" />
</>
)}
<div className="w-px h-4 bg-gray-200 shrink-0" />
<button onClick={fitColumns}
title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout"
className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400
transition-colors whitespace-nowrap">
Fit widths
</button>
</div>
{/* ── Forecast: reaches the server, or opens something over the pivot.
Refresh discards the current view's state and re-runs the load;
Operations is where the version is actually written to. ── */}
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
<div className="flex items-center gap-1.5 flex-wrap ml-auto bg-white border border-gray-200
rounded-md px-2.5 py-1">
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
title="Re-run the load from the database"
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">

View File

@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'
import useAuth from '../auth.jsx'
import ReadOnlyNotice from '../components/ReadOnlyNotice.jsx'
const ROLES = ['ignore', 'dimension', 'value', 'units', 'date', 'filter']
@ -13,6 +15,8 @@ const ROLE_STYLE = {
}
export default function Setup({ refreshSources }) {
const { user: me } = useAuth()
const isAdmin = !!me?.is_admin
const { dark } = useTheme()
const [tables, setTables] = useState([])
const [sources, setSources] = useState([])
@ -31,9 +35,16 @@ export default function Setup({ refreshSources }) {
const [openPeriodIdx, setOpenPeriodIdx] = useState(null)
const periodDropRef = useRef(null)
// Every list here is rendered with .map, so a response that is not an array
// takes the whole app down rather than this one panel -- a 403 body is
// {error}, and `{error}.map` threw during render, which unmounts React and
// leaves a blank page. An endpoint answering something unexpected should cost
// an empty list, not the session.
const asList = (set) => (d) => set(Array.isArray(d) ? d : [])
useEffect(() => {
fetch('/api/tables').then(r => r.json()).then(setTables).catch(console.error)
fetch('/api/dim-period/cols').then(r => r.json()).then(setDimPeriodCols).catch(console.error)
fetch('/api/tables').then(r => r.json()).then(asList(setTables)).catch(console.error)
fetch('/api/dim-period/cols').then(r => r.json()).then(asList(setDimPeriodCols)).catch(console.error)
loadSources()
}, [])
@ -48,7 +59,8 @@ export default function Setup({ refreshSources }) {
}, [openPeriodIdx])
function loadSources() {
fetch('/api/sources').then(r => r.json()).then(data => {
fetch('/api/sources').then(r => r.json()).then(raw => {
const data = Array.isArray(raw) ? raw : []
setSources(data)
// check sql status for each source
data.forEach(s => {
@ -63,7 +75,8 @@ export default function Setup({ refreshSources }) {
function selectSource(source) {
setSelectedSource(source)
setColsDirty(false)
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(data => {
fetch(`/api/sources/${source.id}/cols`).then(r => r.json()).then(raw => {
const data = Array.isArray(raw) ? raw : []
setCols(data)
setEditedCols(data.map(c => ({ ...c })))
})
@ -209,7 +222,13 @@ export default function Setup({ refreshSources }) {
.map(c => c.cname)
return (
<div className="h-full flex overflow-hidden text-sm">
<div className="h-full flex flex-col overflow-hidden text-sm">
{!isAdmin && <ReadOnlyNotice what="source registration and column roles" />}
{/* A real box here rather than `display: contents`, because this one has to
be the flex row the two panes sit in. Baseline uses `contents` instead,
where the parent's layout must be left alone. `disabled` is a DOM
property and applies either way. */}
<fieldset disabled={!isAdmin} className="flex-1 min-h-0 flex overflow-hidden border-0 p-0 m-0">
{/* All Tables */}
<div className="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
@ -553,6 +572,7 @@ export default function Setup({ refreshSources }) {
</div>
)}
</fieldset>
</div>
)
}