From db16b0ca5d3113fbf208943c3f17fa1de3096941 Mon Sep 17 00:00:00 2001 From: xarmian Date: Wed, 23 Sep 2026 13:36:27 -0400 Subject: [PATCH] feat(web): the schema editor marks a terminal option as abandoned, and saving no longer strips abandoned_options (BUG-2347) (#1468) * feat(web): the schema editor marks a terminal option as abandoned, and saving no longer strips abandoned_options (BUG-2347) Each terminal option in the collection schema editor gets a "counts as abandoned" toggle (the lead's added scope): abandoned values are left out of the changelog, standup and report throughput. It also closes a hole #1464 opened: the Edit modal rebuilds every FieldDef from its EditableField on save, and that type had no abandoned list, so saving a collection in the web UI silently stripped abandoned_options. The field state now carries it, hydrates it, and saves only values that are still terminal (the server refuses any other). Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N * fix(web): an option's terminal and abandoned marks follow its rename, and each toggle names its option (BUG-2347, codex round 1 on #1468) Markings are keyed by option value, and the option input was a plain bind, so renaming a marked option left its marks on the old value, which the save then filtered out: a renamed "cancelled" silently stopped being terminal and abandoned. Terminal marks had the same pre-existing gap. The input now carries both marks to the new value (unless another row still holds the old one). The Done and Abandoned toggles' accessible labels name their option. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N --- .../collections/CreateCollectionModal.svelte | 3 + .../collections/EditCollectionModal.svelte | 6 + .../components/collections/FieldEditor.svelte | 107 +++++++- .../abandonedOptions.svelte.test.ts | 249 ++++++++++++++++++ .../collections/field-editor-types.ts | 19 ++ 5 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/collections/abandonedOptions.svelte.test.ts diff --git a/web/src/lib/components/collections/CreateCollectionModal.svelte b/web/src/lib/components/collections/CreateCollectionModal.svelte index 9ec81639f..2da76e778 100644 --- a/web/src/lib/components/collections/CreateCollectionModal.svelte +++ b/web/src/lib/components/collections/CreateCollectionModal.svelte @@ -10,6 +10,7 @@ coerceDefault, fieldFromDef, isSafeDoneFieldKey, + keptAbandoned, typeSupportsDefault, validateFieldKey, type EditableField @@ -316,6 +317,8 @@ ) { const terms = f.terminalOptions.filter((t) => def.options!.includes(t)); if (terms.length > 0) def.terminal_options = terms; + const ab = keptAbandoned(f.abandonedOptions, terms); + if (ab.length > 0) def.abandoned_options = ab; } // Advanced controls (T3 / TASK-596). Only emit when set so // payloads stay compact and round-trip with existing schemas. diff --git a/web/src/lib/components/collections/EditCollectionModal.svelte b/web/src/lib/components/collections/EditCollectionModal.svelte index 106c2f54b..6eee51bed 100644 --- a/web/src/lib/components/collections/EditCollectionModal.svelte +++ b/web/src/lib/components/collections/EditCollectionModal.svelte @@ -12,6 +12,7 @@ coerceDefault, defaultsEqual, isSafeDoneFieldKey, + keptAbandoned, typeSupportsDefault, validateFieldKey, type EditableField @@ -316,6 +317,7 @@ options: f.options ? [...f.options] : [], originalOptions: f.options ? [...f.options] : [], terminalOptions: f.terminal_options ? [...f.terminal_options] : [], + abandonedOptions: f.abandoned_options ? [...f.abandoned_options] : [], required: f.required, computed: f.computed, collection: f.collection, @@ -541,6 +543,8 @@ ) { const terms = f.terminalOptions.filter((t) => def.options!.includes(t)); if (terms.length > 0) def.terminal_options = terms; + const ab = keptAbandoned(f.abandonedOptions, terms); + if (ab.length > 0) def.abandoned_options = ab; } // Gate type-specific advanced values by the current type so // stale hidden values from a previous type (e.g. a number @@ -619,6 +623,8 @@ ) { const terms = f.terminalOptions.filter((t) => def.options!.includes(t)); if (terms.length > 0) def.terminal_options = terms; + const ab = keptAbandoned(f.abandonedOptions, terms); + if (ab.length > 0) def.abandoned_options = ab; } // Advanced controls (T3 / TASK-596). Only emit when set so // payloads stay compact and round-trip with existing schemas. diff --git a/web/src/lib/components/collections/FieldEditor.svelte b/web/src/lib/components/collections/FieldEditor.svelte index 5cc42dedd..c0ebd9246 100644 --- a/web/src/lib/components/collections/FieldEditor.svelte +++ b/web/src/lib/components/collections/FieldEditor.svelte @@ -105,6 +105,24 @@ field.keyTouched = true; } + // Markings are keyed by option VALUE, so an edited option must carry its + // terminal and abandoned marks to the new value (codex round 1 on #1468): + // a plain bind left them on the old value, which the save then filtered + // out, silently un-marking a renamed "cancelled" and counting it shipped. + function renameOption(optIndex: number, next: string) { + const prev = field.options[optIndex]; + field.options[optIndex] = next; + if (prev === next) return; + for (const marks of [field.terminalOptions, field.abandonedOptions]) { + const at = marks.indexOf(prev); + if (at < 0) continue; + // Another row may still hold the old value; its marking stays. + if (field.options.includes(prev)) continue; + if (marks.includes(next)) marks.splice(at, 1); + else marks[at] = next; + } + } + function removeOption(optIndex: number) { field.options.splice(optIndex, 1); } @@ -117,6 +135,11 @@ const idx = field.terminalOptions.indexOf(option); if (idx >= 0) { field.terminalOptions.splice(idx, 1); + // An option that no longer closes an item cannot close it without + // delivering either: the server refuses abandoned_options outside + // terminal_options (BUG-2347). + const ab = field.abandonedOptions.indexOf(option); + if (ab >= 0) field.abandonedOptions.splice(ab, 1); } else { field.terminalOptions.push(option); } @@ -126,6 +149,23 @@ return field.terminalOptions.includes(option); } + // "Counts as abandoned" (BUG-2347): a terminal option that closes an item + // WITHOUT delivering it (cancelled, rejected, overturned…). The changelog, + // standup and report throughput leave those items out. Offered only on a + // terminal option. + function toggleAbandoned(option: string) { + const idx = field.abandonedOptions.indexOf(option); + if (idx >= 0) { + field.abandonedOptions.splice(idx, 1); + } else { + field.abandonedOptions.push(option); + } + } + + function isAbandoned(option: string): boolean { + return field.abandonedOptions.includes(option); + } + // ── Advanced section ───────────────────────────────────────────────────── // Contains: required, default value, suffix (number only), relation target. // Auto-expanded when the field already has any advanced value configured so @@ -330,6 +370,11 @@ Saved {/if} + Abandoned? {#if !isActiveDoneField} @@ -348,7 +393,8 @@ renameOption(oi, e.currentTarget.value)} placeholder="option name" /> + {#if showsTerminalColumn} + {#if isTerminal(field.options[oi])} + + {:else} + + {/if} + {/if}