diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index af453fe..f6e9193 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -99,11 +99,15 @@ function Segmented({ options, value, onChange }) { ) } -function Submit({ onClick, children, disabled }) { +function Submit({ onClick, children, disabled, busy }) { return ( - ) @@ -887,6 +891,7 @@ export default function OperationPanel({ dock, slices, setSlices, distinctSlices, viewScope = [], + opBusy = null, applyMode, setApplyMode, currentTotals, activeOp, setActiveOp, @@ -912,6 +917,7 @@ export default function OperationPanel({ const shifting = !!cloneOffset && cloneOffset.trim() !== '' && cloneOffset.trim() !== '0 days' const setNote = activeOp === 'scale' ? setScaleNote : activeOp === 'recode' ? setRecodeNote : setCloneNote const OP_LABEL = { scale: 'Apply Scale', recode: 'Apply Recode', clone: 'Apply Clone' } + const OP_BUSY_LABEL = { scale: 'Applying…', recode: 'Recoding…', clone: 'Cloning…' } return (
@@ -1084,8 +1090,15 @@ export default function OperationPanel({ placeholder="optional" className={`${TEXT} w-48`} /> -
- submitOp(activeOp)}>{OP_LABEL[activeOp]} +
+ submitOp(activeOp)} busy={!!opBusy}> + {opBusy ? OP_BUSY_LABEL[opBusy] : OP_LABEL[activeOp]} + + {opBusy && ( + + Writing rows — the pivot updates when it finishes. + + )}
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 8f9fa72..ce2416a 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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 // inside or the slice it prints is not the slice that gets written 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) // 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 { const res = await fetch(`/api/versions/${versionId}/${op}`, { 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 === 'recode') { setRecodeNote('') } 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) { @@ -1728,6 +1739,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio dock, slices, setSlices, viewScope, + opBusy, applyMode, setApplyMode, currentTotals, activeOp, setActiveOp,