Show that an operation is running

Apply Scale sat there live and silent for the whole round trip, so on a large
slice the only sign anything was happening was the absence of a result -- and
a second click applied the change twice.

The button now spins, reads "Applying…", and refuses further clicks until the
write returns, with a line beside it saying the pivot updates when it
finishes. Cleared in a finally, so a failure releases it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 13:32:59 -04:00
parent f2734ab5b2
commit 0d05dc3baa
2 changed files with 31 additions and 6 deletions

View File

@ -99,11 +99,15 @@ function Segmented({ options, value, onChange }) {
) )
} }
function Submit({ onClick, children, disabled }) { function Submit({ onClick, children, disabled, busy }) {
return ( return (
<button onClick={onClick} disabled={disabled} <button onClick={onClick} disabled={disabled || busy}
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap"> disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap
inline-flex items-center gap-2">
{busy && (
<span className="inline-block w-3 h-3 rounded-full border-2 border-white/40 border-t-white animate-spin" />
)}
{children} {children}
</button> </button>
) )
@ -887,6 +891,7 @@ export default function OperationPanel({
dock, dock,
slices, setSlices, distinctSlices, slices, setSlices, distinctSlices,
viewScope = [], viewScope = [],
opBusy = null,
applyMode, setApplyMode, applyMode, setApplyMode,
currentTotals, currentTotals,
activeOp, setActiveOp, activeOp, setActiveOp,
@ -912,6 +917,7 @@ export default function OperationPanel({
const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days' const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days'
const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote
const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' } const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' }
const OP_BUSY_LABEL = { scale: 'Applying…', recode: 'Recoding…', clone: 'Cloning…' }
return ( return (
<div className={horizontal ? 'flex flex-row items-start p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}> <div className={horizontal ? 'flex flex-row items-start p-3 gap-5 min-w-0' : 'flex flex-col p-3 gap-3 min-w-0'}>
@ -1084,8 +1090,15 @@ export default function OperationPanel({
placeholder="optional" className={`${TEXT} w-48`} /> placeholder="optional" className={`${TEXT} w-48`} />
</Field> </Field>
<div className="pt-1"> <div className="pt-1 flex items-center gap-3">
<Submit onClick={() => submitOp(activeOp)}>{OP_LABEL[activeOp]}</Submit> <Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
{opBusy ? OP_BUSY_LABEL[opBusy] : OP_LABEL[activeOp]}
</Submit>
{opBusy && (
<span className="text-gray-500 leading-snug">
Writing rows the pivot updates when it finishes.
</span>
)}
</div> </div>
<RequestPreview payload={buildPayload(activeOp)} /> <RequestPreview payload={buildPayload(activeOp)} />
</div> </div>

View File

@ -81,6 +81,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// the same filter, for display: the panel has to show the scope it is acting // the same filter, for display: the panel has to show the scope it is acting
// inside or the slice it prints is not the slice that gets written // inside or the slice it prints is not the slice that gets written
const [viewScope, setViewScope] = useState([]) const [viewScope, setViewScope] = useState([])
// which operation is in flight, so the panel can say so and refuse a second click
const [opBusy, setOpBusy] = useState(null)
const [msg, setMsg] = useState(null) const [msg, setMsg] = useState(null)
// layouts // layouts
@ -1462,6 +1464,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} }
} }
// A large scale writes a row per coordinate and can run for a while, and
// until now the button stayed live and said nothing -- so the only feedback
// that anything was happening was the absence of feedback, and clicking
// twice would apply it twice.
setOpBusy(op)
try { try {
const res = await fetch(`/api/versions/${versionId}/${op}`, { const res = await fetch(`/api/versions/${versionId}/${op}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
@ -1483,7 +1490,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) } if (op === 'scale') { setScaleInputs({}); setScaleNote(''); fetchCurrentTotals(slices) }
if (op === 'recode') { setRecodeNote('') } if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') } if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') } } catch (err) {
flash(err.message, 'error')
} finally {
setOpBusy(null)
}
} }
async function lookupDerivedCols(col, value, setter) { async function lookupDerivedCols(col, value, setter) {
@ -1728,6 +1739,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
dock, dock,
slices, setSlices, slices, setSlices,
viewScope, viewScope,
opBusy,
applyMode, setApplyMode, applyMode, setApplyMode,
currentTotals, currentTotals,
activeOp, setActiveOp, activeOp, setActiveOp,