Put the forecasts in the nav, and lock the admin pages down

Nav. The source and version dropdowns sat in a status bar that is
otherwise read-only, so they did not read as the controls they were.
The forecasts are now listed in the sidebar above the three steps, each
row carrying its status and a star for the default. Not nested under
the Forecast button, which is where this started: Baseline works on the
same version, so hanging the list off one step would either
misrepresent the scope or duplicate it under two.

The source is gone from the nav entirely -- gs.osm_skinny means nothing
to someone editing a forecast. The sidebar lists every source's
versions and sets the source from the one picked, since a version
carries its source_id; `versions` stays scoped to the selected source,
because Baseline and Forecast are, so the sidebar gets its own list
rather than widening what versions means everywhere. The picker only
appears on Setup now, which is the one place the raw table name is the
right label.

The status bar keeps what follows FROM the selection -- the forecast's
name and its row count. The table it writes to moved into the hover
panel, still click-to-copy, since pasting it into psql is why it was
ever on screen.

Permissions. requireAdmin existed in lib/auth.js and was applied to
nothing, so all 26 non-admin accounts could call every Setup and
Baseline endpoint directly. It is now on source register/deregister,
col_meta writes, Generate SQL, dim refresh, version create/delete/
close/reopen, PUT /versions/:id, all of plans.js, and the baseline and
reference loads. Deliberately not on any read, nor on the forecast
operations, which carry their own owner and territory rules.

Both pages stay visible and go read-only, via one <fieldset disabled>
each rather than a flag threaded through forty-five controls, with a
banner saying why. The Tags panel on Baseline is excluded: its
permission is ownership, not admin, and a rep may rename the
initiatives they named themselves.

The table browser reads are open to everyone, because gating what a
page needs in order to render turned Setup into a 403 -- which it then
rendered as a crash, since the error body was passed to .map during
render and unmounting React blanks the app. Setup's list fetches now
coerce to an array, so an unexpected response costs an empty panel and
not the session. That fragility predated the gating.

Toolbar. The zone labels were the same visual weight as the buttons
they name, and the rules between groups were the same weight as the
zone borders, so nothing separated at the level it belonged to. Labels
are heavier and darker, group rules are stronger and taller, and the
forecast zone has a container of its own instead of floating.

Fit widths is removed, button and function. It measured the leaf header
over viewport rows only, so anything below the fold never counted; it
passed an option draw() does not have and raced draw()'s throttle,
leaving columns cleared but unmeasured at their floor widths; and the
persist that followed saved nothing, because saveColumnSizes returns
only overrides, which resetAutoSize had just emptied. Recoverable from
git if it is ever worth fixing rather than dropping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 01:57:24 -04:00
parent b378ab7639
commit 915981b4a7
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,6 +52,11 @@ 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">
{/* 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 || ''}
@ -63,44 +68,28 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
? <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>
)
}