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
This commit is contained in:
xarmian
2026-09-23 13:36:27 -04:00
committed by GitHub
parent 4333c0bd54
commit db16b0ca5d
5 changed files with 381 additions and 3 deletions
@@ -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.
@@ -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.
@@ -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 @@
<span class="done-pill done-pill--saved">Saved</span>
{/if}
</span>
<span
class="options-col-abandoned"
title="Mark a terminal option that closes an item WITHOUT delivering it (cancelled, rejected, overturned…). The changelog, standup and reports leave those items out of completed work."
>Abandoned?</span
>
<span class="options-col-spacer"></span>
</div>
{#if !isActiveDoneField}
@@ -348,7 +393,8 @@
<input
class="option-name-input"
type="text"
bind:value={field.options[oi]}
value={field.options[oi]}
oninput={(e) => renameOption(oi, e.currentTarget.value)}
placeholder="option name"
/>
<button
@@ -360,8 +406,8 @@
? 'Marked as terminal (click to unmark)'
: 'Mark as terminal — items with this value are considered done / closed'}
aria-label={isTerminal(field.options[oi])
? 'Unmark as terminal'
: 'Mark as terminal'}
? `Unmark ${field.options[oi]} as terminal`
: `Mark ${field.options[oi]} as terminal`}
>
{#if isTerminal(field.options[oi])}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -399,6 +445,25 @@
</svg>
{/if}
</button>
{#if showsTerminalColumn}
{#if isTerminal(field.options[oi])}
<button
class="option-abandoned-toggle"
class:active={isAbandoned(field.options[oi])}
type="button"
aria-pressed={isAbandoned(field.options[oi])}
onclick={() => toggleAbandoned(field.options[oi])}
title={isAbandoned(field.options[oi])
? 'Counts as abandoned: left out of the changelog and completed work (click to unmark)'
: 'Mark as abandoned: this value closes an item without delivering it'}
aria-label={isAbandoned(field.options[oi])
? `Unmark ${field.options[oi]} as abandoned`
: `Mark ${field.options[oi]} as abandoned`}
>&#10007;</button>
{:else}
<span class="option-abandoned-placeholder" aria-hidden="true"></span>
{/if}
{/if}
<button
class="option-remove-btn"
type="button"
@@ -888,6 +953,42 @@
color: var(--accent-green, #22c55e);
}
.options-col-abandoned {
width: 40px;
flex-shrink: 0;
text-align: center;
font-size: 0.75em;
color: var(--text-muted);
}
.option-abandoned-toggle,
.option-abandoned-placeholder {
width: 40px;
flex-shrink: 0;
}
.option-abandoned-toggle {
background: none;
border: none;
color: var(--text-muted);
opacity: 0.5;
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
transition:
color 0.15s,
opacity 0.15s;
}
.option-abandoned-toggle:hover {
opacity: 1;
}
.option-abandoned-toggle.active {
color: var(--accent-red, #ef4444);
opacity: 1;
}
.option-remove-btn {
background: none;
border: none;
@@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync, mount, unmount, tick } from 'svelte';
import type { Collection } from '$lib/types';
import { fieldFromDef, keptAbandoned, type EditableField } from './field-editor-types';
/**
* BUG-2347 PR 2: the schema editor's "counts as abandoned" toggle, and the
* round trip that makes it safe. Before this, EditableField had no abandoned
* list, so SAVING a collection in the web editor rebuilt every FieldDef
* without `abandoned_options` and silently stripped the declaration.
*/
const updateMock = vi.hoisted(() => vi.fn());
vi.mock('$lib/api/client', () => ({
api: {
collections: {
update: (...a: unknown[]) => updateMock(...a),
list: vi.fn().mockResolvedValue([]),
delete: vi.fn()
},
items: { listByCollection: vi.fn().mockResolvedValue([]) }
},
isConflictOrNotFound: () => false
}));
const { default: FieldEditor } = await import('./FieldEditor.svelte');
const { default: EditCollectionModal } = await import('./EditCollectionModal.svelte');
let host: HTMLElement;
let app: Record<string, unknown> | null = null;
async function settle(): Promise<void> {
flushSync();
for (let i = 0; i < 8; i++) {
await Promise.resolve();
await tick();
}
flushSync();
}
beforeEach(() => {
host = document.createElement('div');
document.body.appendChild(host);
updateMock.mockReset();
});
afterEach(() => {
if (app) unmount(app as never);
app = null;
host.remove();
});
describe('keptAbandoned', () => {
it('keeps only abandoned values still being saved as terminal', () => {
expect(keptAbandoned(['cancelled', 'dropped'], ['done', 'cancelled'])).toEqual(['cancelled']);
});
});
describe('FieldEditor abandoned toggle', () => {
function statusField(): EditableField {
return fieldFromDef(
{
key: 'status',
label: 'Status',
type: 'select',
options: ['open', 'done', 'cancelled'],
terminal_options: ['done', 'cancelled']
},
true
);
}
function row(option: string): HTMLElement {
const input = [...host.querySelectorAll<HTMLInputElement>('.option-name-input')].find((i) => i.value === option);
expect(input, `a row for ${option}`).toBeTruthy();
return input!.closest('.option-row') as HTMLElement;
}
it('offers the toggle only on terminal options, and toggles the field state', async () => {
const field = $state(statusField());
app = mount(FieldEditor, { target: host, props: { field, index: 0, total: 1 } }) as Record<string, unknown>;
await settle();
expect(row('open').querySelector('.option-abandoned-toggle')).toBeNull();
const toggle = row('cancelled').querySelector<HTMLButtonElement>('.option-abandoned-toggle')!;
expect(toggle).toBeTruthy();
toggle.click();
await settle();
expect(field.abandonedOptions).toEqual(['cancelled']);
expect(toggle.getAttribute('aria-pressed')).toBe('true');
// Unmarking the option as terminal clears its abandoned mark too.
row('cancelled').querySelector<HTMLButtonElement>('.option-done-toggle')!.click();
await settle();
expect(field.terminalOptions).toEqual(['done']);
expect(field.abandonedOptions).toEqual([]);
});
});
describe('FieldEditor option rename (codex round 1 on #1468)', () => {
function typeInto(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
it('carries terminal and abandoned marks to the renamed value, keystroke by keystroke', async () => {
const field = $state(
fieldFromDef(
{
key: 'status',
label: 'Status',
type: 'select',
options: ['open', 'done', 'cancelled'],
terminal_options: ['done', 'cancelled'],
abandoned_options: ['cancelled']
},
true
)
);
app = mount(FieldEditor, { target: host, props: { field, index: 0, total: 1 } }) as Record<string, unknown>;
await settle();
const input = [...host.querySelectorAll<HTMLInputElement>('.option-name-input')].find((i) => i.value === 'cancelled')!;
for (const step of ['cancelle', 'cancele', 'canceled']) {
typeInto(input, step);
await settle();
}
expect(field.options).toEqual(['open', 'done', 'canceled']);
expect(field.terminalOptions).toEqual(['done', 'canceled']);
expect(field.abandonedOptions).toEqual(['canceled']);
});
it('leaves a mark on the old value while another row still holds it', async () => {
const field = $state(
fieldFromDef(
{
key: 'status',
label: 'Status',
type: 'select',
options: ['done', 'done'],
terminal_options: ['done']
},
true
)
);
app = mount(FieldEditor, { target: host, props: { field, index: 0, total: 1 } }) as Record<string, unknown>;
await settle();
const inputs = host.querySelectorAll<HTMLInputElement>('.option-name-input');
typeInto(inputs[1], 'shipped');
await settle();
expect(field.terminalOptions).toEqual(['done']);
});
});
describe('EditCollectionModal round trip', () => {
it('saves abandoned_options it loaded, instead of stripping them', async () => {
updateMock.mockImplementation(async () => ({}));
const collection = {
id: 'c1',
slug: 'plans',
name: 'Plans',
icon: '',
description: '',
prefix: 'PLAN',
schema: JSON.stringify({
fields: [
{
key: 'status',
label: 'Status',
type: 'select',
options: ['open', 'completed', 'overturned'],
terminal_options: ['completed', 'overturned'],
abandoned_options: ['overturned']
}
]
}),
settings: '{}',
updated_at: '2026-09-23T00:00:00Z'
} as unknown as Collection;
app = mount(EditCollectionModal, {
target: host,
props: { open: true, collection, wsSlug: 'ws', initialSection: 'fields' }
}) as Record<string, unknown>;
await settle();
const save = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
/^\s*save/i.test(b.textContent ?? '')
);
expect(save, 'a Save button').toBeTruthy();
save!.click();
await settle();
expect(updateMock).toHaveBeenCalledTimes(1);
const data = updateMock.mock.calls[0][2] as { schema: string };
const status = JSON.parse(data.schema).fields.find((f: { key: string }) => f.key === 'status');
expect(status.abandoned_options).toEqual(['overturned']);
});
it('saves the RENAMED value as terminal and abandoned, with the value migration', async () => {
updateMock.mockImplementation(async () => ({}));
const collection = {
id: 'c2',
slug: 'plans2',
name: 'Plans',
icon: '',
description: '',
prefix: 'PLANB',
schema: JSON.stringify({
fields: [
{
key: 'status',
label: 'Status',
type: 'select',
options: ['open', 'completed', 'overturned'],
terminal_options: ['completed', 'overturned'],
abandoned_options: ['overturned']
}
]
}),
settings: '{}',
updated_at: '2026-09-23T00:00:00Z'
} as unknown as Collection;
app = mount(EditCollectionModal, {
target: host,
props: { open: true, collection, wsSlug: 'ws', initialSection: 'fields' }
}) as Record<string, unknown>;
await settle();
const input = [...document.querySelectorAll<HTMLInputElement>('.option-name-input')].find(
(i) => i.value === 'overturned'
);
expect(input, 'the overturned option row').toBeTruthy();
input!.value = 'reversed';
input!.dispatchEvent(new Event('input', { bubbles: true }));
await settle();
[...document.querySelectorAll<HTMLButtonElement>('button')].find((b) => /^\s*save/i.test(b.textContent ?? ''))!.click();
await settle();
const data = updateMock.mock.calls[0][2] as { schema: string; migrations?: { rename_options?: Record<string, string> }[] };
const status = JSON.parse(data.schema).fields.find((f: { key: string }) => f.key === 'status');
expect(status.terminal_options).toEqual(['completed', 'reversed']);
expect(status.abandoned_options).toEqual(['reversed']);
expect(JSON.stringify(data.migrations ?? [])).toContain('reversed');
});
});
@@ -31,6 +31,13 @@ export interface EditableField {
options: string[];
originalOptions: string[];
terminalOptions: string[];
/**
* Subset of terminalOptions that closes WITHOUT delivering
* (`abandoned_options`, BUG-2347). Always an array, like terminalOptions.
* It MUST round-trip: a save that rebuilt the FieldDef without it would
* silently strip the server-side declaration.
*/
abandonedOptions: string[];
required?: boolean;
computed?: boolean;
collection?: string;
@@ -404,6 +411,7 @@ export function blankField(): EditableField {
options: [],
originalOptions: [],
terminalOptions: [],
abandonedOptions: [],
keyTouched: false
};
}
@@ -426,6 +434,7 @@ export function fieldFromDef(def: FieldDef, existing: boolean): EditableField {
options: def.options ? [...def.options] : [],
originalOptions: existing && def.options ? [...def.options] : [],
terminalOptions: def.terminal_options ? [...def.terminal_options] : [],
abandonedOptions: def.abandoned_options ? [...def.abandoned_options] : [],
required: def.required,
computed: def.computed,
collection: def.collection,
@@ -440,3 +449,13 @@ export function fieldFromDef(def: FieldDef, existing: boolean): EditableField {
keyTouched: true
};
}
/**
* The abandoned markings to save beside `terms` (the terminal options being
* saved): only those still terminal, so an option renamed, removed or
* unmarked terminal cannot leave a declaration the server refuses
* (abandoned_options must be a subset of terminal_options, BUG-2347).
*/
export function keptAbandoned(abandoned: readonly string[], terms: readonly string[]): string[] {
return abandoned.filter((a) => terms.includes(a));
}