Let a forecast be renamed
PUT /versions/:id has taken name and description since it existed; only the fallback-name fields ever called it, so the one thing a version's name could not be was changed. A This forecast panel on the Baseline page now saves both on blur. Safe at any time, closed versions included: nothing derives from the name. The forecast table is keyed on the id, as are pf.log, pf.layout and the UI's localStorage. Two edges the route left open. (source_id, name) is unique, so a rename can collide -- it now answers 409 with the same sentence the POST has always used, rather than a raw Postgres message that reads as a server fault. And the UPDATE COALESCEs a falsy name back to the existing one, so a blank box would have looked saved and changed nothing; that is refused where it is typed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b014f7815a
commit
738e177793
16
CLAUDE.md
16
CLAUDE.md
@ -323,6 +323,22 @@ together. `columns` selects which *measures* appear, not individual split
|
|||||||
combinations.
|
combinations.
|
||||||
|
|
||||||
|
|
||||||
|
## Renaming a version
|
||||||
|
|
||||||
|
Safe at any time, including on a closed one. Nothing derives from the name: the
|
||||||
|
forecast table is `fc_{tname}_{version_id}`, `pf.log` and `pf.layout` key on the
|
||||||
|
id, and the UI's `localStorage` keys are id-based. The Baseline page's **This
|
||||||
|
forecast** panel saves `name` and `description` on blur through
|
||||||
|
`PUT /api/versions/:id`, which has accepted both all along — it was only ever
|
||||||
|
called for the fallback names.
|
||||||
|
|
||||||
|
Two edges the panel handles: `(source_id, name)` is unique, so the route
|
||||||
|
answers 409 on a collision rather than a raw Postgres error; and the `UPDATE`
|
||||||
|
COALESCEs a falsy name back to the existing one, so a blank box is refused
|
||||||
|
client-side rather than looking saved and changing nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Cloning a version's definition
|
## Cloning a version's definition
|
||||||
|
|
||||||
`POST /sources/:id/versions` takes `clone_from`. It copies the template
|
`POST /sources/:id/versions` takes `clone_from`. It copies the template
|
||||||
|
|||||||
@ -463,6 +463,13 @@ ${colDefs},
|
|||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
// (source_id, name) is unique, so a rename can collide. The POST has
|
||||||
|
// said so for as long as it has existed; this said it with a raw
|
||||||
|
// Postgres message, which reads as a server fault rather than a name
|
||||||
|
// that is already taken.
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'A version with that name already exists for this source' });
|
||||||
|
}
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -529,9 +529,15 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
// The names a row falls back to when nobody has named it. Blank means "use the
|
// The names a row falls back to when nobody has named it. Blank means "use the
|
||||||
// built-in", so these save on blur like the segment fields and an empty box is
|
// built-in", so these save on blur like the segment fields and an empty box is
|
||||||
// a meaningful value rather than a missing one.
|
// a meaningful value rather than a missing one.
|
||||||
async function saveVersionName(field, value) {
|
async function saveVersionName(field, value, opts = {}) {
|
||||||
const next = value.trim()
|
const next = value.trim()
|
||||||
if (next === (selectedVersion?.[field] || '')) return
|
if (next === (selectedVersion?.[field] || '')) return
|
||||||
|
// The route COALESCEs a falsy name back to the existing one, so a blank box
|
||||||
|
// would look saved and change nothing. Say so instead.
|
||||||
|
if (field === 'name' && !next) {
|
||||||
|
flash('A version needs a name', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/versions/${versionId}`, {
|
const res = await fetch(`/api/versions/${versionId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -540,7 +546,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
})
|
})
|
||||||
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||||
await refreshVersions(sourceId)
|
await refreshVersions(sourceId)
|
||||||
flash('Saved — reload the Forecast view to see it')
|
// A rename shows everywhere the version is listed and needs no reload; a
|
||||||
|
// fallback name is baked into the aggregated row, so the pivot cannot
|
||||||
|
// re-derive it in place.
|
||||||
|
flash(opts.message || 'Saved — reload the Forecast view to see it')
|
||||||
} catch (err) { flash(err.message, 'error') }
|
} catch (err) { flash(err.message, 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,6 +585,36 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Name and description. Nothing depends on the name -- the forecast table
|
||||||
|
is fc_{tname}_{version_id}, pf.log and pf.layout key on the id, and the
|
||||||
|
UI's localStorage keys are id-based -- so it is safe to change at any
|
||||||
|
time, including on a closed version, where correcting a name is still
|
||||||
|
legitimate. Saves on blur, like the fallback names below. */}
|
||||||
|
{versionId && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded p-3 flex items-end gap-3 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-500 uppercase tracking-wide w-full">This forecast</span>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Name</label>
|
||||||
|
<input
|
||||||
|
key={`name-${versionId}-${selectedVersion?.name || ''}`}
|
||||||
|
defaultValue={selectedVersion?.name || ''}
|
||||||
|
onBlur={e => saveVersionName('name', e.target.value, { message: 'Renamed' })}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||||
|
className="border border-gray-200 rounded px-2 py-1 text-sm w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 flex-1 min-w-[16rem]">
|
||||||
|
<label className="text-xs text-gray-500">Description</label>
|
||||||
|
<input
|
||||||
|
key={`desc-${versionId}-${selectedVersion?.description || ''}`}
|
||||||
|
defaultValue={selectedVersion?.description || ''}
|
||||||
|
onBlur={e => saveVersionName('description', e.target.value, { message: 'Saved' })}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||||
|
placeholder="optional"
|
||||||
|
className="border border-gray-200 rounded px-2 py-1 text-sm w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fallback names. Not part of any segment -- they are what the pivot shows
|
{/* Fallback names. Not part of any segment -- they are what the pivot shows
|
||||||
for rows nobody has named, so they belong to the version rather than to
|
for rows nobody has named, so they belong to the version rather than to
|
||||||
a log entry. Blank falls back to the built-in in sql_generator's
|
a log entry. Blank falls back to the built-in in sql_generator's
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user