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:
@@ -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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user