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
|
Baseline.jsx Version management, baseline workbench, reference load
|
||||||
Forecast.jsx Perspective pivot, selection handling, operation dispatch
|
Forecast.jsx Perspective pivot, selection handling, operation dispatch
|
||||||
components/
|
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
|
LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions
|
||||||
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
|
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
|
||||||
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
|
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
|
Both axes collapse the same way: a **depth in `ViewConfig`**, set through
|
||||||
`restore()`.
|
`restore()`.
|
||||||
|
|
||||||
- **Rows** — `group_by_depth`, driven by the `EXPAND 0 1 2 3` buttons via
|
- **Rows** — `group_by_depth`, driven by the `Rows` buttons via `applyDepth()`
|
||||||
`applyDepth()`
|
- **Columns** — `split_by_depth`, driven by the `Columns` buttons via
|
||||||
- **Columns** — `split_by_depth`, driven by the `COLUMNS` buttons via
|
|
||||||
`applySplitDepth()`
|
`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
|
Both are **1-based**: they count the levels to show, where the imperative
|
||||||
`view.set_depth()` counts the boundary below them. `server.cpp` does
|
`view.set_depth()` counts the boundary below them. `server.cpp` does
|
||||||
`ctx1->set_depth(row_pivot_depth - 1)` and
|
`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
|
## 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).
|
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 { useState, useEffect, useRef } from 'react'
|
||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
import LayoutMenu from '../components/LayoutMenu.jsx'
|
import LayoutMenu from '../components/LayoutMenu.jsx'
|
||||||
|
import DepthButtons from '../components/DepthButtons.jsx'
|
||||||
import useAuth from '../auth.jsx'
|
import useAuth from '../auth.jsx'
|
||||||
import OperationPanel from '../components/OperationPanel.jsx'
|
import OperationPanel from '../components/OperationPanel.jsx'
|
||||||
import BridgeView from '../components/BridgeView.jsx'
|
import BridgeView from '../components/BridgeView.jsx'
|
||||||
@ -115,6 +116,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// re-renders when either changes.
|
// re-renders when either changes.
|
||||||
const [splitFull, setSplitFull] = useState([])
|
const [splitFull, setSplitFull] = useState([])
|
||||||
const [splitDepth, setSplitDepth] = useState(null)
|
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 [slices, setSlices] = useState([])
|
||||||
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
|
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
|
// split_full is the legacy key, from when collapsing truncated split_by
|
||||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
||||||
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
|
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
|
// restore() has already applied group_by_depth; this only syncs the
|
||||||
// toolbar. expand_depth is the legacy key, from when depth was imperative
|
// toolbar. expand_depth is the legacy key, from when depth was imperative
|
||||||
// and had to be stored beside the config rather than in it.
|
// 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)
|
await viewer.restore(cfg)
|
||||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
|
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
|
// 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
|
// dirty dot has something to compare against. Read back rather than
|
||||||
// reusing cfg: restore() normalises, so the live config is what a later
|
// 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()
|
const live = await viewer.save()
|
||||||
adoptSplit(live.split_by || [],
|
adoptSplit(live.split_by || [],
|
||||||
live.split_by_depth != null ? live.split_by_depth - 1 : null)
|
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
|
// the plugin element is replaced when the plugin changes, so re-assert
|
||||||
hideSplitTotal()
|
hideSplitTotal()
|
||||||
const cfg = await captureConfig()
|
const cfg = await captureConfig()
|
||||||
@ -1515,6 +1522,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
||||||
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
|
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)
|
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
|
||||||
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
|
||||||
|
|
||||||
@ -1899,73 +1907,68 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
{/* Toolbar */}
|
{/* 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">
|
<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">
|
||||||
|
|
||||||
<LayoutMenu
|
{/* ── View: everything here changes how the loaded rows are displayed.
|
||||||
layouts={layouts}
|
Nothing in this zone talks to the server or alters the forecast,
|
||||||
activeLayoutId={activeLayoutId}
|
which is the whole reason it is fenced off from the zone on the
|
||||||
dirty={layoutDirty}
|
right — the two used to sit in one undifferentiated row of
|
||||||
onApply={applyLayout}
|
buttons separated by identical rules, so "Fit" and "Bridge"
|
||||||
onSave={handleSaveOver}
|
read as peers when one is a column width and the other opens
|
||||||
onSaveAs={handleSaveAs}
|
the version's history. ── */}
|
||||||
onSetVisibility={(l, v) => patchLayout(l, { visibility: v },
|
<div className="flex items-center gap-2.5 flex-wrap bg-gray-50 border border-gray-100
|
||||||
v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)}
|
rounded px-2 py-1">
|
||||||
onSetDefault={l => patchLayout(l, { is_default: true },
|
|
||||||
`“${l.name}” opens this forecast`)}
|
|
||||||
onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
|
|
||||||
onDelete={deleteLayout}
|
|
||||||
onReset={resetLayout} />
|
|
||||||
|
|
||||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
<LayoutMenu
|
||||||
|
layouts={layouts}
|
||||||
|
activeLayoutId={activeLayoutId}
|
||||||
|
dirty={layoutDirty}
|
||||||
|
onApply={applyLayout}
|
||||||
|
onSave={handleSaveOver}
|
||||||
|
onSaveAs={handleSaveAs}
|
||||||
|
onSetVisibility={(l, v) => patchLayout(l, { visibility: v },
|
||||||
|
v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)}
|
||||||
|
onSetDefault={l => patchLayout(l, { is_default: true },
|
||||||
|
`“${l.name}” opens this forecast`)}
|
||||||
|
onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
|
||||||
|
onDelete={deleteLayout}
|
||||||
|
onReset={resetLayout} />
|
||||||
|
|
||||||
<button onClick={fitColumns}
|
{/* Rows and Columns are the same control on the two axes — a depth in
|
||||||
title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout"
|
ViewConfig — so they are built from one renderer and read alike.
|
||||||
className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400
|
The buttons name the level they reveal: "2" meant nothing without
|
||||||
transition-colors whitespace-nowrap">
|
already knowing what group_by held, and the old fixed 0–3 range
|
||||||
Fit
|
offered levels that did not exist and hid ones that did. */}
|
||||||
</button>
|
{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" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
{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" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Expand group */}
|
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Expand</span>
|
<button onClick={fitColumns}
|
||||||
{[0, 1, 2, 3].map(d => (
|
title="Size every column to its contents, releasing any widths pinned by dragging or carried in a saved layout"
|
||||||
<button key={d} onClick={() => applyDepth(d)}
|
className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400
|
||||||
className={`border rounded px-1.5 py-0.5 transition-colors
|
transition-colors whitespace-nowrap">
|
||||||
${expandDepth === d ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
|
Fit widths
|
||||||
{d}
|
</button>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{splitFull.length > 0 && (
|
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
||||||
<>
|
Refresh discards the current view's state and re-runs the load;
|
||||||
<div className="w-px h-4 bg-gray-200 shrink-0" />
|
Operations is where the version is actually written to. ── */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
|
||||||
{/* Column hierarchy group — the split_by equivalent of Expand */}
|
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
|
||||||
<div className="flex items-center gap-1.5">
|
title="Re-run the load from the database"
|
||||||
<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}
|
|
||||||
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">
|
||||||
{loading ? 'Loading…' : 'Refresh data'}
|
{loading ? 'Loading…' : 'Refresh data'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user