diff --git a/x-pack/platform/packages/shared/kbn-inference-endpoint-ui-common/src/components/configuration/configuration_field.test.tsx b/x-pack/platform/packages/shared/kbn-inference-endpoint-ui-common/src/components/configuration/configuration_field.test.tsx new file mode 100644 index 0000000000000..646727fd9f9a0 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-endpoint-ui-common/src/components/configuration/configuration_field.test.tsx @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConfigInputField, ConfigNumberField } from './configuration_field'; +import { FieldType } from '../../types/types'; +import type { ConfigEntryView } from '../../types/types'; + +describe('ConfigInputField', () => { + const createConfigEntry = (overrides: Partial = {}): ConfigEntryView => ({ + key: 'url', + isValid: true, + label: 'URL', + description: 'The URL endpoint', + validationErrors: [], + required: false, + sensitive: false, + value: null, + default_value: 'https://api.example.com/v1', + updatable: true, + type: FieldType.STRING, + supported_task_types: ['text_embedding'], + ...overrides, + }); + + const defaultProps = { + isLoading: false, + validateAndSetConfigValue: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with default value when value is null', () => { + const configEntry = createConfigEntry({ value: null }); + render(); + + const input = screen.getByTestId('url-input'); + expect(input).toHaveValue('https://api.example.com/v1'); + }); + + it('renders with actual value when value is provided', () => { + const configEntry = createConfigEntry({ value: 'https://custom.url.com' }); + render(); + + const input = screen.getByTestId('url-input'); + expect(input).toHaveValue('https://custom.url.com'); + }); + + it('allows user to clear the field completely without resetting to default', () => { + const validateAndSetConfigValue = jest.fn(); + const configEntry = createConfigEntry({ + value: null, + default_value: 'https://api.example.com/v1', + }); + + render( + + ); + + const input = screen.getByTestId('url-input'); + + // User clears the entire field + fireEvent.change(input, { target: { value: '' } }); + + // The input should be empty, not reset to default + expect(input).toHaveValue(''); + expect(validateAndSetConfigValue).toHaveBeenCalledWith(''); + }); + + it('does not reset to default after rerender when field is cleared', () => { + const validateAndSetConfigValue = jest.fn(); + const configEntry = createConfigEntry({ + value: null, + default_value: 'https://api.example.com/v1', + }); + + const { rerender } = render( + + ); + + const input = screen.getByTestId('url-input'); + + // User clears the entire field + fireEvent.change(input, { target: { value: '' } }); + + // Simulate parent form updating value prop to null (as it converts '' to null) + rerender( + + ); + + // Should still be empty, not reset to default + expect(input).toHaveValue(''); + }); + + it('allows user to type a new value after clearing', () => { + const validateAndSetConfigValue = jest.fn(); + const configEntry = createConfigEntry({ + value: null, + default_value: 'https://api.example.com/v1', + }); + + render( + + ); + + const input = screen.getByTestId('url-input'); + + // User clears and types new value + fireEvent.change(input, { target: { value: '' } }); + fireEvent.change(input, { target: { value: 'https://new.url.com' } }); + + expect(input).toHaveValue('https://new.url.com'); + expect(validateAndSetConfigValue).toHaveBeenLastCalledWith('https://new.url.com'); + }); + + it('is disabled when isLoading is true', () => { + const configEntry = createConfigEntry(); + render(); + + const input = screen.getByTestId('url-input'); + expect(input).toBeDisabled(); + }); + + it('is disabled in edit mode when field is not updatable', () => { + const configEntry = createConfigEntry({ updatable: false }); + render(); + + const input = screen.getByTestId('url-input'); + expect(input).toBeDisabled(); + }); + + it('shows invalid state when isValid is false', () => { + const configEntry = createConfigEntry({ isValid: false }); + render(); + + const input = screen.getByTestId('url-input'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); +}); + +describe('ConfigNumberField', () => { + const createConfigEntry = (overrides: Partial = {}): ConfigEntryView => ({ + key: 'max_tokens', + isValid: true, + label: 'Max Tokens', + description: 'Maximum number of tokens', + validationErrors: [], + required: false, + sensitive: false, + value: null, + default_value: 1024, + updatable: true, + type: FieldType.INTEGER, + supported_task_types: ['text_embedding'], + ...overrides, + }); + + const defaultProps = { + isLoading: false, + validateAndSetConfigValue: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with default value when value is null', () => { + const configEntry = createConfigEntry({ value: null }); + render(); + + const input = screen.getByTestId('max_tokens-number'); + expect(input).toHaveValue(1024); + }); + + it('renders with actual value when value is provided', () => { + const configEntry = createConfigEntry({ value: 2048 }); + render(); + + const input = screen.getByTestId('max_tokens-number'); + expect(input).toHaveValue(2048); + }); + + it('allows user to change the value', () => { + const validateAndSetConfigValue = jest.fn(); + const configEntry = createConfigEntry({ + value: null, + default_value: 1024, + }); + + render( + + ); + + const input = screen.getByTestId('max_tokens-number'); + fireEvent.change(input, { target: { value: '512' } }); + + expect(input).toHaveValue(512); + expect(validateAndSetConfigValue).toHaveBeenCalledWith('512'); + }); + + it('is disabled when isLoading is true', () => { + const configEntry = createConfigEntry(); + render(); + + const input = screen.getByTestId('max_tokens-number'); + expect(input).toBeDisabled(); + }); + + it('is disabled when isPreconfigured is true', () => { + const configEntry = createConfigEntry(); + render( + + ); + + const input = screen.getByTestId('max_tokens-number'); + expect(input).toBeDisabled(); + }); + + it('is disabled in edit mode when field is not updatable', () => { + const configEntry = createConfigEntry({ updatable: false }); + render(); + + const input = screen.getByTestId('max_tokens-number'); + expect(input).toBeDisabled(); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-inference-endpoint-ui-common/src/components/configuration/configuration_field.tsx b/x-pack/platform/packages/shared/kbn-inference-endpoint-ui-common/src/components/configuration/configuration_field.tsx index da66c0b213a83..a2bc4febf2166 100644 --- a/x-pack/platform/packages/shared/kbn-inference-endpoint-ui-common/src/components/configuration/configuration_field.tsx +++ b/x-pack/platform/packages/shared/kbn-inference-endpoint-ui-common/src/components/configuration/configuration_field.tsx @@ -47,8 +47,12 @@ export const ConfigInputField: React.FC = ({ ); useEffect(() => { - setInnerValue(!value || value.toString().length === 0 ? defaultValue : value); - }, [defaultValue, value]); + // Only sync from external value if it has actual content + // Don't reset to default when user clears the field (value becomes null) + if (value != null && String(value).length > 0) { + setInnerValue(value); + } + }, [value]); return ( = ({ const { isValid, value, default_value: defaultValue, key, updatable } = configEntry; const [innerValue, setInnerValue] = useState(value ?? defaultValue); useEffect(() => { - setInnerValue(!value || value.toString().length === 0 ? defaultValue : value); - }, [defaultValue, value]); + // Only sync from external value if it has actual content + // Don't reset to default when user clears the field (value becomes null) + if (value != null && String(value).length > 0) { + setInnerValue(value); + } + }, [value]); return (