refactor(client-ui-plugin-config): stage card edits behind an explicit save

Controls committed on blur, which turned leaving a field into a durable,
revision-fenced document write the user could neither preview nor undo, and
silently discarded a draft the field did not accept.

A card's form now owns the staged text every control renders, and Save is the
only point where drafts become writes. Reset stages the composed default the
same way; an invalid draft blocks the save with its reason instead of being
dropped; Discard drops the drafts; a collapsed card marks that it holds some.
The Host stays the only authority on whether a value was accepted, so the save
reads the section back and keeps the drafts of a save that did not land.
This commit is contained in:
Yichen Jiang
2026-08-10 22:51:12 +08:00
parent dae6cad065
commit 5d3f392cd3
23 changed files with 1352 additions and 787 deletions
@@ -1,12 +1,12 @@
// @vitest-environment jsdom
/**
* Field-control behavior: when a draft becomes a write, what a bad draft does
* instead, and how an overridden field offers its reset.
* Field-control behavior: what a control renders for a staged draft, how an
* overridden field offers its reset, and that a control never writes on its own.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NumberField, SecretField, TextField } from '../src/client/fields.tsx'
import { SecretField, ValueField } from '../src/client/fields.tsx'
afterEach(cleanup)
@@ -16,328 +16,138 @@ const frame = {
hint: 'How long one command may run.',
overriddenLabel: 'Overridden',
resetLabel: 'Reset to default',
invalidLabel: 'Enter a number.',
disabled: false,
overridden: false,
invalid: false,
}
describe('NumberField', () => {
it('commits a changed draft on blur', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
describe('ValueField', () => {
it('stages every keystroke without writing', () => {
const onEdit = vi.fn()
render(<ValueField {...frame} text="60000" onEdit={onEdit} onReset={vi.fn()} />)
fireEvent.change(input, { target: { value: '9000' } })
fireEvent.blur(input)
fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } })
expect(onCommit).toHaveBeenCalledWith(9_000)
expect(onEdit).toHaveBeenCalledWith('9000')
})
it('commits on Enter through the blur the key triggers', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
it('renders the staged text it is given rather than a draft of its own', () => {
const { rerender } = render(<ValueField {...frame} text="60000" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
fireEvent.change(input, { target: { value: '1234' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
rerender(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
expect(onCommit).toHaveBeenCalledWith(1_234)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
})
it('restores the last good value instead of committing a draft that is not a number', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
fireEvent.change(input, { target: { value: 'soon' } })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
expect(input).toHaveProperty('value', '60000')
})
it('writes nothing when the draft settles on the value already shown', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
fireEvent.change(input, { target: { value: '60000' } })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
})
it('offers the reset only while the field is overridden', () => {
it('offers the reset only while an override would stand', () => {
const onReset = vi.fn()
const { rerender } = render(
<NumberField {...frame} overridden={false} onReset={onReset} value={9_000} onCommit={vi.fn()} />,
)
const { rerender } = render(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={onReset} />)
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
rerender(
<NumberField {...frame} overridden onReset={onReset} value={9_000} onCommit={vi.fn()} />,
)
rerender(<ValueField {...frame} overridden text="9000" onEdit={vi.fn()} onReset={onReset} />)
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }))
expect(screen.getByText('Overridden')).toBeTruthy()
expect(onReset).toHaveBeenCalledOnce()
})
it('re-seeds the draft when the authoritative value changes underneath', () => {
const { rerender } = render(
<NumberField {...frame} overridden onReset={vi.fn()} value={9_000} onCommit={vi.fn()} />,
)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
it('replaces the hint with the reason an invalid draft cannot be saved', () => {
render(<ValueField {...frame} invalid text="soon" onEdit={vi.fn()} onReset={vi.fn()} />)
rerender(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={vi.fn()} />,
)
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
expect(screen.getByText('Enter a number.')).toBeTruthy()
expect(screen.queryByText('How long one command may run.')).toBeNull()
expect(screen.getByLabelText('Command timeout').getAttribute('aria-invalid')).toBe('true')
})
it('ignores a keystroke that is not Enter', () => {
const onCommit = vi.fn()
it('hints a numeric keypad and renders a placeholder when asked', () => {
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={60_000} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
fireEvent.change(input, { target: { value: '9000' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onCommit).not.toHaveBeenCalled()
})
it('renders an absent value as empty rather than as a number nobody chose', () => {
const onCommit = vi.fn()
render(
<NumberField {...frame} overridden={false} onReset={vi.fn()} value={undefined} onCommit={onCommit} />,
)
const input = screen.getByLabelText('Command timeout')
expect(input).toHaveProperty('value', '')
// A draft typed and then cleared restores the same emptiness, not a zero.
fireEvent.change(input, { target: { value: 'abc' } })
fireEvent.blur(input)
expect(input).toHaveProperty('value', '')
expect(onCommit).not.toHaveBeenCalled()
})
it('suppresses every interaction while disabled', () => {
const onCommit = vi.fn()
const onReset = vi.fn()
render(
<NumberField
<ValueField
{...frame}
disabled
overridden
onReset={onReset}
value={9_000}
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Command timeout')
expect(input).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
expect(onCommit).not.toHaveBeenCalled()
expect(onReset).not.toHaveBeenCalled()
})
})
describe('TextField', () => {
it('commits the trimmed draft', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
overridden={false}
onReset={vi.fn()}
value=""
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
fireEvent.change(input, { target: { value: ' https://search.test/v1 ' } })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('https://search.test/v1')
})
it('commits an emptied draft, which clears the field', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
overridden
onReset={vi.fn()}
value="https://search.test/v1"
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('')
})
it('renders its placeholder and commits on Enter', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
numeric
placeholder="https://api.deepseek.com"
overridden={false}
text=""
onEdit={vi.fn()}
onReset={vi.fn()}
value=""
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
const input = screen.getByLabelText('Command timeout')
expect(input.getAttribute('inputmode')).toBe('numeric')
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
fireEvent.change(input, { target: { value: 'https://other.test' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('https://other.test')
})
it('ignores a keystroke that is not Enter and writes nothing unchanged', () => {
const onCommit = vi.fn()
render(
<TextField
{...frame}
label="Endpoint"
overridden={false}
onReset={vi.fn()}
value="https://search.test/v1"
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('Endpoint')
it('disables the control and its reset while the document is read-only', () => {
render(<ValueField {...frame} disabled overridden text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
fireEvent.keyDown(input, { key: 'a' })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
})
})
describe('SecretField', () => {
it('commits a non-empty draft and clears the control after writing', () => {
const onCommit = vi.fn()
const secret = {
id: 'key',
label: 'API key',
hint: 'Stored outside the settings file.',
disabled: false,
}
it('stages the draft and never renders it', () => {
const onEdit = vi.fn()
render(
<SecretField
{...frame}
label="API key"
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: ' ds-secret ' } })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('ds-secret')
expect(input).toHaveProperty('value', '')
})
it('keeps the stored key when the draft is left blank', () => {
const onCommit = vi.fn()
render(
<SecretField
{...frame}
label="API key"
configured
stateLabel="A key is configured."
onCommit={onCommit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
expect(screen.getByText('A key is configured.')).toBeTruthy()
})
it('ignores a keystroke that is not Enter', () => {
const onCommit = vi.fn()
render(
<SecretField
{...frame}
label="API key"
configured={false}
stateLabel="No key is configured."
onCommit={onCommit}
onEdit={onEdit}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: 'ds-secret' } })
fireEvent.keyDown(input, { key: 'Tab' })
expect(onCommit).not.toHaveBeenCalled()
expect(onEdit).toHaveBeenCalledWith('ds-secret')
expect(input).toHaveProperty('type', 'password')
})
it('never renders the value it writes', () => {
render(
<SecretField
{...frame}
label="API key"
configured
stateLabel="A key is configured."
onCommit={vi.fn()}
/>,
)
expect(screen.getByLabelText('API key')).toHaveProperty('type', 'password')
})
it('commits on Enter and stays disabled when the document is read-only', () => {
const onCommit = vi.fn()
it('reports the configured state the Host holds', () => {
const { rerender } = render(
<SecretField
{...frame}
label="API key"
{...secret}
text=""
configured={false}
stateLabel="No key is configured."
onCommit={onCommit}
onEdit={vi.fn()}
/>,
)
const input = screen.getByLabelText('API key')
fireEvent.change(input, { target: { value: 'ds-secret' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith('ds-secret')
expect(screen.getByText('No key is configured.')).toBeTruthy()
rerender(
<SecretField
{...frame}
disabled
label="API key"
{...secret}
text="ds-secret"
configured
stateLabel="A key is configured."
onCommit={onCommit}
onEdit={vi.fn()}
/>,
)
expect(screen.getByText('A key is configured.')).toBeTruthy()
expect(screen.getByLabelText('API key')).toHaveProperty('value', 'ds-secret')
})
it('disables the control when it is told to', () => {
render(
<SecretField
{...secret}
disabled
text=""
configured
stateLabel="A key is configured."
onEdit={vi.fn()}
/>,
)