Say what each toolbar control actually does
The toolbar was one row of buttons separated by identical hairlines, so a column width and the version's change history read as peers. Split it: a view zone on its own ground -- layout, the two depth controls, fit widths, none of which reach the server -- and the forecast zone that re-loads, opens a window, or writes. The row depth buttons were a fixed 0 1 2 3. A number means nothing without already knowing what group_by holds, and a fixed range offers levels a two-field pivot does not have while hiding the fifth of one that does. They are now built from the live group_by and named after the level they reveal, which is what the column axis already did -- so both are drawn by one DepthButtons component, since both are the same thing: a depth in ViewConfig. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e9782419a
commit
cc5de46b9e
27
CLAUDE.md
27
CLAUDE.md
@ -46,6 +46,7 @@ ui/src/
|
||||
Baseline.jsx Version management, baseline workbench, reference load
|
||||
Forecast.jsx Perspective pivot, selection handling, operation dispatch
|
||||
components/
|
||||
DepthButtons.jsx One axis's collapse control; both axes are drawn from it
|
||||
LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions
|
||||
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
|
||||
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
|
||||
@ -231,11 +232,15 @@ gets written.
|
||||
Both axes collapse the same way: a **depth in `ViewConfig`**, set through
|
||||
`restore()`.
|
||||
|
||||
- **Rows** — `group_by_depth`, driven by the `EXPAND 0 1 2 3` buttons via
|
||||
`applyDepth()`
|
||||
- **Columns** — `split_by_depth`, driven by the `COLUMNS` buttons via
|
||||
- **Rows** — `group_by_depth`, driven by the `Rows` buttons via `applyDepth()`
|
||||
- **Columns** — `split_by_depth`, driven by the `Columns` buttons via
|
||||
`applySplitDepth()`
|
||||
|
||||
Both sets are drawn by `components/DepthButtons.jsx` from the axis's own field
|
||||
list (`groupFull` / `splitFull`), so each button is named after the level it
|
||||
reveals. The row axis used to be a fixed `0 1 2 3`, which named nothing and
|
||||
offered levels a two-field `group_by` does not have.
|
||||
|
||||
Both are **1-based**: they count the levels to show, where the imperative
|
||||
`view.set_depth()` counts the boundary below them. `server.cpp` does
|
||||
`ctx1->set_depth(row_pivot_depth - 1)` and
|
||||
@ -367,6 +372,22 @@ layout, since `restore()` is all-or-nothing.
|
||||
|
||||
---
|
||||
|
||||
## The Forecast toolbar is two zones
|
||||
|
||||
Left, on its own tinted ground: **view** — `Layout ▾`, the `Rows` and `Columns`
|
||||
depth buttons, `Fit widths`. Nothing there reaches the server or changes the
|
||||
forecast; it is all `ViewConfig` and `plugin_config` against rows already loaded.
|
||||
|
||||
Right: **forecast** — `Refresh data`, `Change log`, `Bridge`, `Operations`. These
|
||||
re-run the load, open something over the pivot, or write to the version.
|
||||
|
||||
They were one undifferentiated row separated by identical hairlines, which made
|
||||
`Fit` and `Bridge` read as peers when one is a column width and the other is the
|
||||
version's history. The split is the point; the tint is just what makes it
|
||||
visible.
|
||||
|
||||
---
|
||||
|
||||
## Slice mechanics
|
||||
|
||||
When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side).
|
||||
|
||||
26
ui/src/components/DepthButtons.jsx
Normal file
26
ui/src/components/DepthButtons.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
// One axis's collapse control. Rows and columns are the same thing — a depth in
|
||||
// ViewConfig, counting the levels to show — so they are drawn by one component
|
||||
// rather than two that happen to look alike.
|
||||
//
|
||||
// The buttons are named after the level they reveal. The row axis used to be a
|
||||
// fixed 0 1 2 3: a number means nothing without already knowing what group_by
|
||||
// holds, and a fixed range offers levels that do not exist while hiding ones
|
||||
// that do.
|
||||
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>{label}</span>
|
||||
{Array.from({ length: levels.length + 1 }, (_, n) => (
|
||||
<button key={n} onClick={() => onPick(n)}
|
||||
title={n === 0
|
||||
? `Collapse ${label.toLowerCase()} to a single total`
|
||||
: `Show ${label.toLowerCase()} down to ${levels.slice(0, n).join(' › ')}`}
|
||||
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
|
||||
${depth === n ? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||||
{n === 0 ? totalLabel : levels[n - 1]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import useTheme from '../theme.jsx'
|
||||
import LayoutMenu from '../components/LayoutMenu.jsx'
|
||||
import DepthButtons from '../components/DepthButtons.jsx'
|
||||
import useAuth from '../auth.jsx'
|
||||
import OperationPanel from '../components/OperationPanel.jsx'
|
||||
import BridgeView from '../components/BridgeView.jsx'
|
||||
@ -115,6 +116,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
// re-renders when either changes.
|
||||
const [splitFull, setSplitFull] = useState([])
|
||||
const [splitDepth, setSplitDepth] = useState(null)
|
||||
// The row hierarchy, for the same reason: the depth buttons name the level
|
||||
// they show rather than counting to a number nobody can map back to a field.
|
||||
const [groupFull, setGroupFull] = useState([])
|
||||
|
||||
const [slices, setSlices] = useState([])
|
||||
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
|
||||
@ -920,6 +924,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
// split_full is the legacy key, from when collapsing truncated split_by
|
||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
||||
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
|
||||
setGroupFull(cfg.group_by || [])
|
||||
// restore() has already applied group_by_depth; this only syncs the
|
||||
// toolbar. expand_depth is the legacy key, from when depth was imperative
|
||||
// and had to be stored beside the config rather than in it.
|
||||
@ -947,6 +952,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
}
|
||||
await viewer.restore(cfg)
|
||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
||||
setGroupFull(cfg.group_by || [])
|
||||
// Name what we landed on, so the menu says which layout this is and the
|
||||
// dirty dot has something to compare against. Read back rather than
|
||||
// reusing cfg: restore() normalises, so the live config is what a later
|
||||
@ -971,6 +977,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
const live = await viewer.save()
|
||||
adoptSplit(live.split_by || [],
|
||||
live.split_by_depth != null ? live.split_by_depth - 1 : null)
|
||||
setGroupFull(live.group_by || [])
|
||||
// the plugin element is replaced when the plugin changes, so re-assert
|
||||
hideSplitTotal()
|
||||
const cfg = await captureConfig()
|
||||
@ -1515,6 +1522,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
await viewer.restore(cfg)
|
||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
||||
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
|
||||
setGroupFull(cfg.group_by || [])
|
||||
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
|
||||
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||
|
||||
@ -1899,6 +1907,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
{/* Toolbar */}
|
||||
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
|
||||
|
||||
{/* ── View: everything here changes how the loaded rows are displayed.
|
||||
Nothing in this zone talks to the server or alters the forecast,
|
||||
which is the whole reason it is fenced off from the zone on the
|
||||
right — the two used to sit in one undifferentiated row of
|
||||
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">
|
||||
|
||||
<LayoutMenu
|
||||
layouts={layouts}
|
||||
activeLayoutId={activeLayoutId}
|
||||
@ -1914,58 +1932,43 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
onDelete={deleteLayout}
|
||||
onReset={resetLayout} />
|
||||
|
||||
{/* Rows and Columns are the same control on the two axes — a depth in
|
||||
ViewConfig — so they are built from one renderer and read alike.
|
||||
The buttons name the level they reveal: "2" meant nothing without
|
||||
already knowing what group_by held, and the old fixed 0–3 range
|
||||
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" />
|
||||
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
|
||||
onPick={applyDepth} totalLabel="Total" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{splitFull.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||
<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
|
||||
Fit widths
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||
|
||||
{/* Expand group */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
|
||||
{[0, 1, 2, 3].map(d => (
|
||||
<button key={d} onClick={() => applyDepth(d)}
|
||||
className={`border rounded px-1.5 py-0.5 transition-colors
|
||||
${expandDepth === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{splitFull.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||
|
||||
{/* Column hierarchy group — the split_by equivalent of Expand */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Columns</span>
|
||||
{Array.from({ length: splitFull.length + 1 }, (_, n) => {
|
||||
const label = n === 0 ? 'Total' : splitFull[n - 1]
|
||||
return (
|
||||
<button key={n} onClick={() => applySplitDepth(n)}
|
||||
title={n === 0
|
||||
? 'Collapse the columns to a single total'
|
||||
: `Show columns down to ${splitFull.slice(0, n).join(' › ')}`}
|
||||
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
|
||||
${splitDepth === n ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||
|
||||
{/* Data group */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => initViewer(versionId, sourceId)} disabled={loading || !versionId}
|
||||
{/* ── 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">
|
||||
<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">
|
||||
{loading ? 'Loading…' : 'Refresh data'}
|
||||
</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user