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:
commit
3426f3525b
@ -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,
|
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
|
||||||
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
|
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
|
||||||
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
|
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');
|
const { fcTable } = require('../lib/utils');
|
||||||
|
|
||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
@ -572,7 +572,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// load baseline rows from source table — additive, no delete
|
// 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 { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
|
||||||
const pf_user = sessionUser(req);
|
const pf_user = sessionUser(req);
|
||||||
const dateOffset = date_offset || '0 days';
|
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
|
// edit a baseline or reference segment in place — only allowed before any
|
||||||
// scale/recode/clone has been applied on this version, since those would
|
// scale/recode/clone has been applied on this version, since those would
|
||||||
// have been calibrated against the old segment's totals.
|
// 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 versionId = parseInt(req.params.id);
|
||||||
const logid = parseInt(req.params.logid);
|
const logid = parseInt(req.params.logid);
|
||||||
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
|
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
|
// 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);
|
const versionId = parseInt(req.params.id);
|
||||||
try {
|
try {
|
||||||
const ctx = await getContext(versionId, 'baseline');
|
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)
|
// 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 { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
|
||||||
const pf_user = sessionUser(req);
|
const pf_user = sessionUser(req);
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
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.
|
// 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 versionId = parseInt(req.params.id);
|
||||||
const b = req.body || {};
|
const b = req.body || {};
|
||||||
if (!['baseline', 'reference'].includes(b.kind)) {
|
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.
|
// 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
|
// The flag-and-value pair rather than COALESCE on the value: clearing a
|
||||||
// label to blank has to be distinguishable from not mentioning it.
|
// 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 pid = parseInt(req.params.pid);
|
||||||
const b = req.body || {};
|
const b = req.body || {};
|
||||||
const keys = [...FIELDS, 'run_logid'].filter(k => b[k] !== undefined);
|
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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)]
|
`DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { generateSQL, buildTerritoryClause } = require('../lib/sql_generator');
|
const { generateSQL, buildTerritoryClause } = require('../lib/sql_generator');
|
||||||
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
|
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
|
||||||
const { sessionTerritory } = require('../lib/auth');
|
const { sessionTerritory, requireAdmin } = require('../lib/auth');
|
||||||
const { sessionUser } = require('../lib/auth');
|
const { sessionUser } = require('../lib/auth');
|
||||||
|
|
||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
@ -22,7 +22,7 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// register a source table
|
// register a source table
|
||||||
// auto-populates col_meta from information_schema with role='ignore'
|
// 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 { schema, tname, label } = req.body;
|
||||||
const created_by = sessionUser(req);
|
const created_by = sessionUser(req);
|
||||||
if (!schema || !tname) {
|
if (!schema || !tname) {
|
||||||
@ -81,7 +81,7 @@ module.exports = function(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// save col_meta — accepts full array, upserts each row
|
// 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 sourceId = parseInt(req.params.id);
|
||||||
const cols = req.body;
|
const cols = req.body;
|
||||||
if (!Array.isArray(cols)) {
|
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
|
// 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);
|
const sourceId = parseInt(req.params.id);
|
||||||
try {
|
try {
|
||||||
const srcResult = await pool.query(
|
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
|
// 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
|
// transaction table is millions of rows -- so it is a deliberate action rather
|
||||||
// than something that happens on a page load.
|
// 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 sourceId = parseInt(req.params.id);
|
||||||
const group = req.params.group;
|
const group = req.params.group;
|
||||||
try {
|
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 {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`DELETE FROM pf.source WHERE id = $1 RETURNING *`,
|
`DELETE FROM pf.source WHERE id = $1 RETURNING *`,
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
|
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) {
|
module.exports = function(pool) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils');
|
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');
|
const { buildTerritoryClause } = require('../lib/sql_generator');
|
||||||
|
|
||||||
// Copy a version's definition onto a freshly created one: its load segments as
|
// 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
|
// create a new version
|
||||||
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
|
// 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 sourceId = parseInt(req.params.id);
|
||||||
const { name, description, exclude_iters, clone_from } = req.body;
|
const { name, description, exclude_iters, clone_from } = req.body;
|
||||||
const created_by = sessionUser(req);
|
const created_by = sessionUser(req);
|
||||||
@ -514,7 +514,7 @@ ${colDefs},
|
|||||||
// The three name columns are flag-and-value pairs rather than COALESCE:
|
// 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
|
// clearing one back to the built-in means writing null, which COALESCE on
|
||||||
// the value alone cannot tell from "not mentioned".
|
// 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,
|
const { name, description, exclude_iters,
|
||||||
adjustment_segment, adjustment_bucket, unlabeled_load,
|
adjustment_segment, adjustment_bucket, unlabeled_load,
|
||||||
is_default } = req.body;
|
is_default } = req.body;
|
||||||
@ -595,7 +595,7 @@ ${colDefs},
|
|||||||
});
|
});
|
||||||
|
|
||||||
// close a version — blocks further edits
|
// 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);
|
const pf_user = sessionUser(req);
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
@ -615,7 +615,7 @@ ${colDefs},
|
|||||||
});
|
});
|
||||||
|
|
||||||
// reopen a closed version
|
// reopen a closed version
|
||||||
router.post('/versions/:id/reopen', async (req, res) => {
|
router.post('/versions/:id/reopen', requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
UPDATE pf.version
|
UPDATE pf.version
|
||||||
@ -635,7 +635,7 @@ ${colDefs},
|
|||||||
|
|
||||||
// delete a version — drops forecast table then deletes version record
|
// delete a version — drops forecast table then deletes version record
|
||||||
// log entries are removed by ON DELETE CASCADE on pf.log.version_id
|
// 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 versionId = parseInt(req.params.id);
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -39,8 +39,35 @@ export default function App() {
|
|||||||
return list
|
return list
|
||||||
}, [sourceId])
|
}, [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() }, [])
|
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
|
// 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:
|
// 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
|
// deleting the selected source otherwise leaves a dead id behind and every
|
||||||
@ -90,7 +117,12 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full text-sm overflow-hidden">
|
<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">
|
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
|
||||||
<StatusBar view={view} {...ctx} />
|
<StatusBar view={view} {...ctx} />
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
|
|||||||
22
ui/src/components/ReadOnlyNotice.jsx
Normal file
22
ui/src/components/ReadOnlyNotice.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150"
|
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>
|
</span>
|
||||||
</div>
|
</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 className="flex flex-col gap-0.5 p-2 flex-1">
|
||||||
{NAV.map(item => {
|
{NAV.map(item => {
|
||||||
const active = view === item.id
|
const active = view === item.id
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
import useAuth from '../auth.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 { dark, setDark } = useTheme()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const showVersion = view === 'baseline' || view === 'forecast'
|
const showVersion = view === 'baseline' || view === 'forecast'
|
||||||
@ -52,6 +52,11 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs relative">
|
<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>
|
<span className="text-gray-400">Source</span>
|
||||||
<select
|
<select
|
||||||
value={sourceId || ''}
|
value={sourceId || ''}
|
||||||
@ -63,44 +68,28 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
|||||||
? <option value="">— no sources —</option>
|
? <option value="">— no sources —</option>
|
||||||
: sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
: sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{showVersion && (
|
{showVersion && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-200">|</span>
|
{/* What is open, and how big it is -- the two things a forecaster
|
||||||
<span className="text-gray-400">Version</span>
|
wants from a status bar. The physical table it writes to is an
|
||||||
<select
|
implementation detail and means nothing to a sales account, so it
|
||||||
value={versionId || ''}
|
has moved into the hover panel and onto the Baseline page, which
|
||||||
onChange={e => setVersionId(e.target.value)}
|
is where a version's plumbing is managed. */}
|
||||||
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 */}
|
|
||||||
{info && (
|
{info && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-200">|</span>
|
<span className="text-gray-200">|</span>
|
||||||
<span className="text-gray-400" title="Operations append to this table">writes to</span>
|
<span
|
||||||
<button
|
|
||||||
onClick={copyTable}
|
|
||||||
onMouseEnter={() => setShow(true)}
|
onMouseEnter={() => setShow(true)}
|
||||||
onMouseLeave={() => setShow(false)}
|
onMouseLeave={() => setShow(false)}
|
||||||
className={`font-mono px-1.5 py-0.5 rounded border hover:bg-gray-50 ${
|
className="font-medium text-gray-700 cursor-default"
|
||||||
info.exists ? 'text-gray-700 border-gray-200' : 'text-amber-700 border-amber-200 bg-amber-50'
|
title="Hover for the write target and row breakdown"
|
||||||
}`}
|
|
||||||
title={info.exists ? 'Click to copy table name' : 'Table does not exist yet'}
|
|
||||||
>
|
>
|
||||||
{copied ? 'copied!' : info.fc_table}
|
{selectedVersion?.name || '—'}
|
||||||
</button>
|
</span>
|
||||||
<span className="text-gray-400 font-mono">
|
<span className={`font-mono ${info.exists ? 'text-gray-400' : 'text-amber-700'}`}>
|
||||||
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
|
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
|
||||||
</span>
|
</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="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">
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||||
<span className="text-gray-400">table</span>
|
<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="text-gray-400">reads from</span>
|
||||||
<span className="font-mono text-gray-700">{info.source}</span>
|
<span className="font-mono text-gray-700">{info.source}</span>
|
||||||
<span className="text-gray-400">total rows</span>
|
<span className="text-gray-400">total rows</span>
|
||||||
|
|||||||
@ -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
|
--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
|
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. */
|
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 {
|
.pf-section-label {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
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-300 { color: var(--text-muted); }
|
||||||
.dark .text-gray-400 { color: var(--text-muted); }
|
.dark .text-gray-400 { color: var(--text-muted); }
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Timeline from '../components/Timeline.jsx'
|
import Timeline from '../components/Timeline.jsx'
|
||||||
import useAuth from '../auth.jsx'
|
import useAuth from '../auth.jsx'
|
||||||
|
import ReadOnlyNotice from '../components/ReadOnlyNotice.jsx'
|
||||||
|
|
||||||
const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
|
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 }) {
|
export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) {
|
||||||
const { user: me } = useAuth()
|
const { user: me } = useAuth()
|
||||||
|
const isAdmin = !!me?.is_admin
|
||||||
const [filterCols, setFilterCols] = useState([])
|
const [filterCols, setFilterCols] = useState([])
|
||||||
const [log, setLog] = 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. */}
|
which for the table is the measured column widths below. */}
|
||||||
<div className="p-4 flex flex-col gap-4 items-start max-w-full">
|
<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 && (
|
{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'}`}>
|
<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}
|
{msg.text}
|
||||||
@ -695,6 +701,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{/* Tags. Not a table anywhere -- a tag is a value repeated across this
|
{/* 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
|
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
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<fieldset disabled={!isAdmin} className="contents">
|
||||||
|
|
||||||
{showNewVersion && (
|
{showNewVersion && (
|
||||||
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
@ -1063,6 +1073,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
|
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1213,60 +1213,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
table.draw()
|
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.
|
// A floor under the row-label columns, not a fit.
|
||||||
//
|
//
|
||||||
// Restoring a layout resets the widths, and the row-header columns are then
|
// 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"
|
buttons separated by identical rules, so "Fit" and "Bridge"
|
||||||
read as peers when one is a column width and the other opens
|
read as peers when one is a column width and the other opens
|
||||||
the version's history. ── */}
|
the version's history. ── */}
|
||||||
<div className="flex items-center gap-2.5 flex-wrap bg-gray-50 border border-gray-100
|
<div className="flex items-center gap-3 flex-wrap bg-gray-50 border border-gray-200
|
||||||
rounded px-2 py-1">
|
rounded-md px-2.5 py-1">
|
||||||
|
|
||||||
<LayoutMenu
|
<LayoutMenu
|
||||||
layouts={layouts}
|
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. */}
|
offered levels that did not exist and hid ones that did. */}
|
||||||
{groupFull.length > 0 && (
|
{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}
|
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
|
||||||
onPick={applyDepth} totalLabel="Total" />
|
onPick={applyDepth} totalLabel="Total" />
|
||||||
</>
|
</>
|
||||||
@ -2128,26 +2074,19 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
|
|
||||||
{splitFull.length > 0 && (
|
{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}
|
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
||||||
onPick={applySplitDepth} totalLabel="Total" />
|
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>
|
</div>
|
||||||
|
|
||||||
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
||||||
Refresh discards the current view's state and re-runs the load;
|
Refresh discards the current view's state and re-runs the load;
|
||||||
Operations is where the version is actually written to. ── */}
|
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}
|
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
|
||||||
title="Re-run the load from the database"
|
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">
|
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
|
import useAuth from '../auth.jsx'
|
||||||
|
import ReadOnlyNotice from '../components/ReadOnlyNotice.jsx'
|
||||||
|
|
||||||
const ROLES = ['ignore', 'dimension', 'value', 'units', 'date', 'filter']
|
const ROLES = ['ignore', 'dimension', 'value', 'units', 'date', 'filter']
|
||||||
|
|
||||||
@ -13,6 +15,8 @@ const ROLE_STYLE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Setup({ refreshSources }) {
|
export default function Setup({ refreshSources }) {
|
||||||
|
const { user: me } = useAuth()
|
||||||
|
const isAdmin = !!me?.is_admin
|
||||||
const { dark } = useTheme()
|
const { dark } = useTheme()
|
||||||
const [tables, setTables] = useState([])
|
const [tables, setTables] = useState([])
|
||||||
const [sources, setSources] = useState([])
|
const [sources, setSources] = useState([])
|
||||||
@ -31,9 +35,16 @@ export default function Setup({ refreshSources }) {
|
|||||||
const [openPeriodIdx, setOpenPeriodIdx] = useState(null)
|
const [openPeriodIdx, setOpenPeriodIdx] = useState(null)
|
||||||
const periodDropRef = useRef(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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/tables').then(r => r.json()).then(setTables).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(setDimPeriodCols).catch(console.error)
|
fetch('/api/dim-period/cols').then(r => r.json()).then(asList(setDimPeriodCols)).catch(console.error)
|
||||||
loadSources()
|
loadSources()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -48,7 +59,8 @@ export default function Setup({ refreshSources }) {
|
|||||||
}, [openPeriodIdx])
|
}, [openPeriodIdx])
|
||||||
|
|
||||||
function loadSources() {
|
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)
|
setSources(data)
|
||||||
// check sql status for each source
|
// check sql status for each source
|
||||||
data.forEach(s => {
|
data.forEach(s => {
|
||||||
@ -63,7 +75,8 @@ export default function Setup({ refreshSources }) {
|
|||||||
function selectSource(source) {
|
function selectSource(source) {
|
||||||
setSelectedSource(source)
|
setSelectedSource(source)
|
||||||
setColsDirty(false)
|
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)
|
setCols(data)
|
||||||
setEditedCols(data.map(c => ({ ...c })))
|
setEditedCols(data.map(c => ({ ...c })))
|
||||||
})
|
})
|
||||||
@ -209,7 +222,13 @@ export default function Setup({ refreshSources }) {
|
|||||||
.map(c => c.cname)
|
.map(c => c.cname)
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* All Tables */}
|
||||||
<div className="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user