diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 539735e934dc7..85c901fd8659e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -481,13 +481,13 @@ packages/kbn-managed-vscode-config @elastic/kibana-operations packages/kbn-managed-vscode-config-cli @elastic/kibana-operations packages/kbn-management/cards_navigation @elastic/platform-deployment-management src/plugins/management @elastic/platform-deployment-management -packages/kbn-management/settings/components/field_input @elastic/platform-deployment-management @elastic/appex-sharedux -packages/kbn-management/settings/components/field_row @elastic/platform-deployment-management @elastic/appex-sharedux -packages/kbn-management/settings/field_definition @elastic/platform-deployment-management @elastic/appex-sharedux +packages/kbn-management/settings/components/field_input @elastic/platform-deployment-management +packages/kbn-management/settings/components/field_row @elastic/platform-deployment-management +packages/kbn-management/settings/field_definition @elastic/platform-deployment-management packages/kbn-management/settings/setting_ids @elastic/appex-sharedux @elastic/platform-deployment-management packages/kbn-management/settings/section_registry @elastic/appex-sharedux @elastic/platform-deployment-management -packages/kbn-management/settings/types @elastic/platform-deployment-management @elastic/appex-sharedux -packages/kbn-management/settings/utilities @elastic/platform-deployment-management @elastic/appex-sharedux +packages/kbn-management/settings/types @elastic/platform-deployment-management +packages/kbn-management/settings/utilities @elastic/platform-deployment-management packages/kbn-management/storybook/config @elastic/platform-deployment-management test/plugin_functional/plugins/management_test_plugin @elastic/kibana-app-services packages/kbn-mapbox-gl @elastic/kibana-gis diff --git a/packages/kbn-management/settings/README.mdx b/packages/kbn-management/settings/README.mdx new file mode 100644 index 0000000000000..fa541b6719091 --- /dev/null +++ b/packages/kbn-management/settings/README.mdx @@ -0,0 +1,11 @@ +# Management Settings + +These packages comprise the Management Advanced Settings application. The source of these components and related types and utilities origingated in the `advancedSettings` plugin. We've abstracted it away into packages first, for Serverless, and later, as a drop-in replacement in the plugin. + +## Notes + +**Be aware**: the functional flow logic we've adopted for these components is not one I would encourage, specifically, using "drilled" onChange handlers and utilizing a composing-component-based store. Ideally, we'd use a Redux store, or, at the very least, a React reducer. + +In the interest of time and compatibility, we've opted to use the pattern from the original components in `advancedSettings`. We plan to revisit the state management and prop-drilling when `advancedSettings` is refactored with these components. + +This is being tracked with https://github.com/elastic/kibana/issues/166579 \ No newline at end of file diff --git a/packages/kbn-management/settings/components/field_input/__stories__/common.tsx b/packages/kbn-management/settings/components/field_input/__stories__/common.tsx index c3e167323d59f..399a125822a35 100644 --- a/packages/kbn-management/settings/components/field_input/__stories__/common.tsx +++ b/packages/kbn-management/settings/components/field_input/__stories__/common.tsx @@ -6,26 +6,30 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState } from 'react'; import type { ComponentMeta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { EuiPanel } from '@elastic/eui'; import { UiSettingsType } from '@kbn/core-ui-settings-common'; -import { SettingType, UiSettingMetadata } from '@kbn/management-settings-types'; import { - useFieldDefinition, - getDefaultValue, -} from '@kbn/management-settings-field-definition/storybook'; + OnChangeFn, + SettingType, + UiSettingMetadata, + UnsavedFieldChange, +} from '@kbn/management-settings-types'; +import { getFieldDefinition } from '@kbn/management-settings-field-definition'; +import { getDefaultValue, getUserValue } from '@kbn/management-settings-utilities/storybook'; import { FieldInputProvider } from '../services'; import { FieldInput as Component, FieldInput } from '../field_input'; -import { InputProps, OnChangeFn } from '../types'; +import { InputProps } from '../types'; /** * Props for a {@link FieldInput} Storybook story. */ -export type StoryProps = Pick, 'value' | 'isDisabled'>; +export type StoryProps = Pick, 'isSavingEnabled'> & + Pick, 'value' | 'userValue'>; /** * Interface defining available {@link https://storybook.js.org/docs/react/writing-stories/parameters parameters} @@ -42,17 +46,11 @@ interface Params { */ export interface Args { /** True if the field is disabled, false otherwise. */ - isDisabled: boolean; + isSavingEnabled: boolean; + userValue: unknown; + value: unknown; } -/** - * Default argument values for a {@link FieldInput} Storybook story. - */ -export const storyArgs = { - /** True if the field is disabled, false otherwise. */ - isDisabled: false, -}; - /** * Utility function for returning a {@link FieldInput} Storybook story * definition. @@ -65,10 +63,13 @@ export const getStory = (title: string, description: string) => title: `Settings/Field Input/${title}`, description, argTypes: { - isDisabled: { - name: 'Is field disabled?', + isSavingEnabled: { + name: 'Is saving enabled?', }, value: { + name: 'Default value', + }, + userValue: { name: 'Current saved value', }, }, @@ -90,30 +91,45 @@ export const getStory = (title: string, description: string) => * @returns A Storybook Story. */ export const getInputStory = (type: SettingType, params: Params = {}) => { - const Story = ({ value, isDisabled = false }: StoryProps) => { + const Story = ({ userValue, value, isSavingEnabled }: StoryProps) => { + const [unsavedChange, setUnsavedChange] = useState< + UnsavedFieldChange | undefined + >(); + const setting: UiSettingMetadata = { type, value, - userValue: value, + userValue, ...params.settingFields, }; - const [field, unsavedChange, onChangeFn] = useFieldDefinition(setting); + const field = getFieldDefinition({ + id: setting.name?.split(' ').join(':').toLowerCase() || setting.type, + setting, + }); const onChange: OnChangeFn = (newChange) => { - onChangeFn(newChange); + setUnsavedChange(newChange); + + action('onChange')({ + type, + unsavedValue: newChange?.unsavedValue, + savedValue: field.savedValue, + }); }; - return ( - - ); + + return ; + }; + + Story.argTypes = { + ...params.argTypes, }; Story.args = { + isSavingEnabled: true, value: getDefaultValue(type), + userValue: getUserValue(type), ...params.argTypes, - ...storyArgs, }; return Story; diff --git a/packages/kbn-management/settings/components/field_input/__stories__/select_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/select_input.stories.tsx index c7571494e7ca8..08a46e3618c6c 100644 --- a/packages/kbn-management/settings/components/field_input/__stories__/select_input.stories.tsx +++ b/packages/kbn-management/settings/components/field_input/__stories__/select_input.stories.tsx @@ -10,6 +10,13 @@ import { getInputStory, getStory } from './common'; const argTypes = { value: { + name: 'Default value', + control: { + type: 'select', + options: ['option1', 'option2', 'option3'], + }, + }, + userValue: { name: 'Current saved value', control: { type: 'select', @@ -25,3 +32,9 @@ const settingFields = { export default getStory('Select Input', 'An input with multiple values.'); export const SelectInput = getInputStory('select' as const, { argTypes, settingFields }); + +SelectInput.args = { + isSavingEnabled: true, + value: 'option1', + userValue: 'option2', +}; diff --git a/packages/kbn-management/settings/components/field_input/field_input.test.tsx b/packages/kbn-management/settings/components/field_input/field_input.test.tsx index 9bbac96b7c12c..0305636fd35e6 100644 --- a/packages/kbn-management/settings/components/field_input/field_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/field_input.test.tsx @@ -56,6 +56,7 @@ describe('FieldInput', () => { options, } as FieldDefinition, onChange: jest.fn(), + isSavingEnabled: true, }; return props; @@ -131,12 +132,12 @@ describe('FieldInput', () => { const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`); fireEvent.change(input, { target: { value: 'new value' } }); - expect(props.onChange).toHaveBeenCalledWith({ value: 'new value' }); + expect(props.onChange).toHaveBeenCalledWith({ type: 'string', unsavedValue: 'new value' }); }); it('disables the input when isDisabled prop is true', () => { const props = getDefaultProps('string'); - const { getByTestId } = render(wrap()); + const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`); expect(input).toBeDisabled(); }); @@ -190,7 +191,7 @@ describe('FieldInput', () => { ...defaultProps.field, type: 'foobar', }, - } as unknown as FieldInputProps<'string'>; + } as unknown as FieldInputProps; expect(() => render(wrap())).toThrowError( 'Unknown or incompatible field type: foobar' diff --git a/packages/kbn-management/settings/components/field_input/field_input.tsx b/packages/kbn-management/settings/components/field_input/field_input.tsx index 301be48ee5141..ae9e3c5c6c536 100644 --- a/packages/kbn-management/settings/components/field_input/field_input.tsx +++ b/packages/kbn-management/settings/components/field_input/field_input.tsx @@ -6,10 +6,12 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useImperativeHandle, useRef } from 'react'; import type { FieldDefinition, + OnChangeFn, + ResetInputRef, SettingType, UnsavedFieldChange, } from '@kbn/management-settings-types'; @@ -40,8 +42,6 @@ import { isUndefinedFieldUnsavedChange, } from '@kbn/management-settings-field-definition/is'; -import { getInputValue } from '@kbn/management-settings-utilities'; - import { BooleanInput, CodeEditorInput, @@ -51,23 +51,20 @@ import { SelectInput, TextInput, ArrayInput, - TextInputProps, } from './input'; -import { OnChangeFn } from './types'; - /** * The props that are passed to the {@link FieldInput} component. */ export interface FieldInputProps { /** The {@link FieldDefinition} for the component. */ - field: FieldDefinition; + field: Pick, 'type' | 'id' | 'name' | 'ariaAttributes'>; /** An {@link UnsavedFieldChange} for the component, if any. */ unsavedChange?: UnsavedFieldChange; /** The `onChange` handler for the input. */ onChange: OnChangeFn; - /** True if the input is disabled, false otherwise. */ - isDisabled?: boolean; + /** True if the input can be saved, false otherwise. */ + isSavingEnabled: boolean; /** True if the value within the input is invalid, false otherwise. */ isInvalid?: boolean; } @@ -84,167 +81,140 @@ const getMismatchError = (type: SettingType, unsavedType?: SettingType) => * * @param props The props for the {@link FieldInput} component. */ -export const FieldInput = (props: FieldInputProps) => { - const { - field, - unsavedChange, - isDisabled = false, - isInvalid = false, - onChange: onChangeProp, - } = props; - const { id, name, ariaAttributes } = field; - - const inputProps = { - ...ariaAttributes, - id, - isDisabled, - isInvalid, - name, - }; - - // These checks might seem excessive or redundant, but they are necessary to ensure that - // the types are honored correctly using type guards. These checks get compiled down to - // checks against the `type` property-- which we were doing in the previous code, albeit - // in an unenforceable way. - // - // Based on the success of a check, we can render the `FieldInput` in a indempotent and - // type-safe way. - // - if (isArrayFieldDefinition(field)) { - // If the composing component mistakenly provides an incompatible `UnsavedFieldChange`, - // we can throw an `Error`. We might consider switching to a `console.error` and not - // rendering the input, but that might be less helpful. - if (!isArrayFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); +export const FieldInput = React.forwardRef>( + (props, ref) => { + const { field, unsavedChange, onChange, isSavingEnabled } = props; + + // Create a ref for those input fields that require an imperative handle. + const inputRef = useRef(null); + + // Create an imperative handle that passes the invocation to any internal input that + // may require it. + useImperativeHandle(ref, () => ({ + reset: () => { + if (inputRef.current) { + inputRef.current.reset(); + } + }, + })); + + const inputProps = { isSavingEnabled, onChange }; + + // These checks might seem excessive or redundant, but they are necessary to ensure that + // the types are honored correctly using type guards. These checks get compiled down to + // checks against the `type` property-- which we were doing in the previous code, albeit + // in an unenforceable way. + // + // Based on the success of a check, we can render the `FieldInput` in a indempotent and + // type-safe way. + // + if (isArrayFieldDefinition(field)) { + // If the composing component mistakenly provides an incompatible `UnsavedFieldChange`, + // we can throw an `Error`. We might consider switching to a `console.error` and not + // rendering the input, but that might be less helpful. + if (!isArrayFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + return ; } - const [value] = getInputValue(field, unsavedChange); - - // This is a safe cast because we've already checked that the type is correct in both - // the `FieldDefinition` and the `UnsavedFieldChange`... no need for a further - // type guard. - const onChange = onChangeProp as OnChangeFn<'array'>; + if (isBooleanFieldDefinition(field)) { + if (!isBooleanFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } - return ; - } - - if (isBooleanFieldDefinition(field)) { - if (!isBooleanFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + return ; } - const [value] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'boolean'>; - - return ; - } + if (isColorFieldDefinition(field)) { + if (!isColorFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } - if (isColorFieldDefinition(field)) { - if (!isColorFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + return ; } - const [value] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'color'>; - - return ; - } + if (isImageFieldDefinition(field)) { + if (!isImageFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } - if (isImageFieldDefinition(field)) { - if (!isImageFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + return ; } - const [value, unsaved] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'image'>; - - return ( - - ); - } - - if (isJsonFieldDefinition(field)) { - if (!isJsonFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + if (isJsonFieldDefinition(field)) { + if (!isJsonFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + return ( + + ); } - const [value] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'json'>; - - return ( - - ); - } - - if (isMarkdownFieldDefinition(field)) { - if (!isMarkdownFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + if (isMarkdownFieldDefinition(field)) { + if (!isMarkdownFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + return ( + + ); } - const [value] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'markdown'>; + if (isNumberFieldDefinition(field)) { + if (!isNumberFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } - return ( - - ); - } - - if (isNumberFieldDefinition(field)) { - if (!isNumberFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + return ; } - const [value] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'number'>; + if (isSelectFieldDefinition(field)) { + if (!isSelectFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } - return ; - } + const { + options: { values: optionValues, labels: optionLabels }, + } = field; - if (isSelectFieldDefinition(field)) { - if (!isSelectFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + return ( + + ); } - const [value] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'select'>; - const { - options: { values: optionValues, labels: optionLabels }, - } = field; + if (isStringFieldDefinition(field)) { + if (!isStringFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } - return ; - } - - if (isStringFieldDefinition(field)) { - if (!isStringFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + return ; } - const [value] = getInputValue(field, unsavedChange); - const onChange = onChangeProp as OnChangeFn<'string'>; - - return ; - } - - if (isUndefinedFieldDefinition(field)) { - if (!isUndefinedFieldUnsavedChange(unsavedChange)) { - throw getMismatchError(field.type, unsavedChange?.type); + if (isUndefinedFieldDefinition(field)) { + if (!isUndefinedFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + return ( + } + unsavedChange={unsavedChange as unknown as UnsavedFieldChange<'string'>} + {...inputProps} + /> + ); } - const [value] = getInputValue(field, unsavedChange); - return ; + throw new Error(`Unknown or incompatible field type: ${field.type}`); } - - throw new Error(`Unknown or incompatible field type: ${field.type}`); -}; +); diff --git a/packages/kbn-management/settings/components/field_input/index.ts b/packages/kbn-management/settings/components/field_input/index.ts index 8570f9af23c93..dc516877e8d51 100644 --- a/packages/kbn-management/settings/components/field_input/index.ts +++ b/packages/kbn-management/settings/components/field_input/index.ts @@ -8,9 +8,4 @@ export { FieldInput, type FieldInputProps } from './field_input'; -export type { - FieldInputKibanaDependencies, - FieldInputServices, - OnChangeFn, - OnChangeParams, -} from './types'; +export type { FieldInputKibanaDependencies, FieldInputServices, InputProps } from './types'; diff --git a/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx index 2b420d39ee2a5..6f80de3039b70 100644 --- a/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx @@ -12,21 +12,30 @@ import userEvent from '@testing-library/user-event'; import { ArrayInput } from './array_input'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; import { wrap } from '../mocks'; +import { InputProps } from '../types'; const name = 'Some array field'; const id = 'some:array:field'; describe('ArrayInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), - value: ['foo', 'bar'], + const onChange = jest.fn(); + const defaultProps: InputProps<'array'> = { + onChange, + field: { + name, + type: 'array', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: ['foo', 'bar'], + }, + isSavingEnabled: true, }; beforeEach(() => { - defaultProps.onChange.mockClear(); + onChange.mockClear(); }); it('renders without errors', () => { @@ -39,6 +48,18 @@ describe('ArrayInput', () => { expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toHaveValue('foo, bar'); }); + it('renders saved value when present', () => { + render( + wrap( + + ) + ); + expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toHaveValue('foo, bar, baz'); + }); + it('formats array when blurred', () => { render(wrap()); const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); @@ -63,11 +84,14 @@ describe('ArrayInput', () => { input.blur(); }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: ['foo', 'bar', 'baz'] }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ + type: 'array', + unsavedValue: ['foo', 'bar', 'baz'], + }); }); it('disables the input when isDisabled prop is true', () => { - const { getByTestId } = render(wrap()); + const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); expect(input).toBeDisabled(); }); diff --git a/packages/kbn-management/settings/components/field_input/input/array_input.tsx b/packages/kbn-management/settings/components/field_input/input/array_input.tsx index d5e4d8f202ec5..dfc92dc980bbd 100644 --- a/packages/kbn-management/settings/components/field_input/input/array_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/array_input.tsx @@ -7,7 +7,10 @@ */ import React, { useEffect, useState } from 'react'; -import { EuiFieldText } from '@elastic/eui'; +import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui'; + +import { getFieldInputValue } from '@kbn/management-settings-utilities'; +import { useUpdate } from '@kbn/management-settings-utilities'; import { InputProps } from '../types'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; @@ -23,19 +26,24 @@ const REGEX = /,\s+/g; * Component for manipulating an `array` field. */ export const ArrayInput = ({ - id, - name, + field, + unsavedChange, + isSavingEnabled, onChange: onChangeProp, - ariaLabel, - isDisabled = false, - value: valueProp, - ariaDescribedBy, }: ArrayInputProps) => { - const [value, setValue] = useState(valueProp?.join(', ')); + const [inputValue] = getFieldInputValue(field, unsavedChange) || []; + const [value, setValue] = useState(inputValue?.join(', ')); + + const onChange: EuiFieldTextProps['onChange'] = (event) => { + const newValue = event.target.value; + setValue(newValue); + }; + + const onUpdate = useUpdate({ onChange: onChangeProp, field }); useEffect(() => { - setValue(valueProp?.join(', ')); - }, [valueProp]); + setValue(inputValue?.join(', ')); + }, [inputValue]); // In the past, each keypress would invoke the `onChange` callback. This // is likely wasteful, so we've switched it to `onBlur` instead. @@ -44,19 +52,21 @@ export const ArrayInput = ({ .replace(REGEX, ',') .split(',') .filter((v) => v !== ''); - onChangeProp({ value: blurValue }); + onUpdate({ type: field.type, unsavedValue: blurValue }); setValue(blurValue.join(', ')); }; + const { id, name, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + return ( setValue(event.target.value)} - {...{ name, onBlur, value }} + {...{ name, onBlur, onChange, value }} /> ); }; diff --git a/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx index 6c713261f11ca..b9f3ac883421b 100644 --- a/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx @@ -13,34 +13,55 @@ import { BooleanInput } from './boolean_input'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; import { wrap } from '../mocks'; +import { InputProps } from '../types'; const name = 'Some boolean field'; const id = 'some:boolean:field'; describe('BooleanInput', () => { - const defaultProps = { - id, - name, - ariaLabel: name, - onChange: jest.fn(), + const onChange = jest.fn(); + const defaultProps: InputProps<'boolean'> = { + onChange, + field: { + name, + type: 'boolean', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: false, + }, + isSavingEnabled: true, }; beforeEach(() => { - defaultProps.onChange.mockClear(); + onChange.mockClear(); + }); + + it('renders false', () => { + render(wrap()); + expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).not.toBeChecked(); }); it('renders true', () => { - render(wrap()); + render( + wrap() + ); expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked(); }); - it('renders false', () => { - render(wrap()); - expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).not.toBeChecked(); + it('renders unsaved value if present', () => { + render( + wrap( + + ) + ); + expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked(); }); it('calls onChange when toggled', () => { - render(wrap()); + render(wrap()); const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); expect(defaultProps.onChange).not.toHaveBeenCalled(); @@ -48,7 +69,7 @@ describe('BooleanInput', () => { fireEvent.click(input); }); - expect(defaultProps.onChange).toBeCalledWith({ value: false }); + expect(defaultProps.onChange).toBeCalledWith({ type: 'boolean', unsavedValue: true }); act(() => { fireEvent.click(input); diff --git a/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx b/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx index d95073c096dd6..4f523da8067eb 100644 --- a/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { EuiSwitch, EuiSwitchProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities'; + import type { InputProps } from '../types'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; @@ -23,16 +25,21 @@ export type BooleanInputProps = InputProps<'boolean'>; * Component for manipulating a `boolean` field. */ export const BooleanInput = ({ - id, - ariaDescribedBy, - ariaLabel, - isDisabled: disabled = false, - name, + field, + unsavedChange, + isSavingEnabled, onChange: onChangeProp, - value, }: BooleanInputProps) => { - const onChange: EuiSwitchProps['onChange'] = (event) => - onChangeProp({ value: event.target.checked }); + const onChange: EuiSwitchProps['onChange'] = (event) => { + const inputValue = event.target.checked; + onUpdate({ type: field.type, unsavedValue: inputValue }); + }; + + const onUpdate = useUpdate({ onChange: onChangeProp, field }); + + const { id, name, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + const [value] = getFieldInputValue(field, unsavedChange); return ( ); }; diff --git a/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx b/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx index b5d0f2da8a86c..dc6c1e15043aa 100644 --- a/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx @@ -9,10 +9,12 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; + import { SettingType } from '@kbn/management-settings-types'; +import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities'; -import { CodeEditor } from '../code_editor'; -import type { InputProps, OnChangeFn } from '../types'; +import { CodeEditor, CodeEditorProps } from '../code_editor'; +import type { InputProps } from '../types'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; type Type = Extract; @@ -23,11 +25,6 @@ type Type = Extract; export interface CodeEditorInputProps extends InputProps { /** The default value of the {@link CodeEditor} component. */ defaultValue?: string; - /** - * The `onChange` event handler, expanded to include both `markdown` - * and `json` - */ - onChange: OnChangeFn; /** * The {@link UiSettingType}, expanded to include both `markdown` * and `json` @@ -41,23 +38,23 @@ export interface CodeEditorInputProps extends InputProps { * TODO: clintandrewhall - `kibana_react` `CodeEditor` does not support `disabled`. */ export const CodeEditorInput = ({ - ariaDescribedBy, - ariaLabel, + field, + unsavedChange, + type, + isSavingEnabled, defaultValue, - id, - isDisabled = false, onChange: onChangeProp, - type, - value: valueProp = '', }: CodeEditorInputProps) => { - const onChange = (newValue: string) => { + const onUpdate = useUpdate({ onChange: onChangeProp, field }); + + const onChange: CodeEditorProps['onChange'] = (inputValue) => { let newUnsavedValue; let errorParams = {}; switch (type) { case 'json': const isJsonArray = Array.isArray(JSON.parse(defaultValue || '{}')); - newUnsavedValue = newValue || (isJsonArray ? '[]' : '{}'); + newUnsavedValue = inputValue || (isJsonArray ? '[]' : '{}'); try { JSON.parse(newUnsavedValue); @@ -71,22 +68,16 @@ export const CodeEditorInput = ({ } break; default: - newUnsavedValue = newValue; + newUnsavedValue = inputValue; } - // TODO: clintandrewhall - should we make this onBlur instead of onChange? - onChangeProp({ - value: newUnsavedValue, - ...errorParams, - }); + onUpdate({ type: field.type, unsavedValue: inputValue, ...errorParams }); }; - // nit: we have to do this because, while the `UiSettingsService` might return - // `null`, the {@link CodeEditor} component doesn't accept `null` as a value. - // - // @see packages/core/ui-settings/core-ui-settings-common/src/ui_settings.ts - // - const value = valueProp === null ? '' : valueProp; + const { id, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + // @ts-expect-error + const [value] = getFieldInputValue(field, unsavedChange); return (
@@ -94,7 +85,7 @@ export const CodeEditorInput = ({ aria-describedby={ariaDescribedBy} aria-label={ariaLabel} data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`} - isReadOnly={isDisabled} + isReadOnly={!isSavingEnabled} name={`${TEST_SUBJ_PREFIX_FIELD}-${id}-editor`} {...{ onChange, type, value }} /> diff --git a/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx index d50b58481a885..0bd73ad51645c 100644 --- a/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx @@ -8,28 +8,36 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; -import { ColorPickerInput } from './color_picker_input'; +import { ColorPickerInput, ColorPickerInputProps } from './color_picker_input'; import { wrap } from '../mocks'; const name = 'Some color field'; const id = 'some:color:field'; describe('ColorPickerInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), - value: '#000000', + const onChange = jest.fn(); + const defaultProps: ColorPickerInputProps = { + onChange, + field: { + name, + type: 'color', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: '#000000', + }, + isSavingEnabled: true, }; - it('renders without errors', () => { - const { container } = render(wrap()); - expect(container).toBeInTheDocument(); + beforeEach(() => { + onChange.mockClear(); }); - it('renders the value prop', () => { - const { getByRole } = render(wrap()); + it('renders without errors', () => { + const { container, getByRole } = render(wrap()); + expect(container).toBeInTheDocument(); const input = getByRole('textbox'); expect(input).toHaveValue('#000000'); }); @@ -39,11 +47,26 @@ describe('ColorPickerInput', () => { const input = getByRole('textbox'); const newValue = '#ffffff'; fireEvent.change(input, { target: { value: newValue } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: newValue }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'color', unsavedValue: newValue }); + }); + + it('calls the onChange prop with an error when the value is malformed', () => { + const { getByRole } = render(wrap()); + const input = getByRole('textbox'); + const newValue = '#1234'; + fireEvent.change(input, { target: { value: newValue } }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ + type: 'color', + unsavedValue: newValue, + isInvalid: true, + error: 'Provide a valid color value', + }); }); it('disables the input when isDisabled prop is true', () => { - const { getByRole } = render(wrap()); + const { getByRole } = render( + wrap() + ); const input = getByRole('textbox'); expect(input).toBeDisabled(); }); diff --git a/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx b/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx index b5c5f7d4de616..8533fc0545eab 100644 --- a/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx @@ -10,8 +10,11 @@ import React from 'react'; import { EuiColorPicker, EuiColorPickerProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { InputProps } from '../types'; +import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities'; +import { UnsavedFieldChange } from '@kbn/management-settings-types'; + import { TEST_SUBJ_PREFIX_FIELD } from '.'; +import { InputProps } from '../types'; /** * Props for a {@link ColorPickerInput} component. @@ -26,31 +29,35 @@ const invalidMessage = i18n.translate('management.settings.fieldInput.color.inva * Component for manipulating a `color` field. */ export const ColorPickerInput = ({ - ariaDescribedBy, - ariaLabel, - id, - isDisabled = false, - isInvalid = false, + field, + unsavedChange, + isSavingEnabled, onChange: onChangeProp, - name, - value: color, }: ColorPickerInputProps) => { + const onUpdate = useUpdate({ onChange: onChangeProp, field }); + const onChange: EuiColorPickerProps['onChange'] = (newColor, { isValid }) => { - if (newColor !== '' && !isValid) { - onChangeProp({ value: newColor, isInvalid: true, error: invalidMessage }); + const update: UnsavedFieldChange<'color'> = { type: field.type, unsavedValue: newColor }; + + if (isValid) { + onUpdate(update); } else { - onChangeProp({ value: newColor }); + onUpdate({ ...update, isInvalid: true, error: invalidMessage }); } }; + const { id, name, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + const [color] = getFieldInputValue(field, unsavedChange); + return ( ); diff --git a/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx index 041d0aba44714..cbbec332330d1 100644 --- a/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { ImageInput } from './image_input'; +import { ImageInput, ImageInputProps } from './image_input'; import { wrap } from '../mocks'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; import { act } from 'react-dom/test-utils'; @@ -18,15 +18,26 @@ const name = 'Some image field'; const id = 'some:image:field'; describe('ImageInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), - hasChanged: false, - isDefaultValue: false, + const onChange = jest.fn(); + const defaultProps: ImageInputProps = { + onChange, + field: { + name, + type: 'image', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: null, + }, + isSavingEnabled: true, }; + beforeEach(() => { + onChange.mockClear(); + }); + it('renders without errors', () => { const { container } = render(wrap()); expect(container).toBeInTheDocument(); @@ -48,7 +59,7 @@ describe('ImageInput', () => { }); it('disables the input when isDisabled prop is true', () => { - const { getByTestId } = render(wrap()); + const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); expect(input).toBeDisabled(); }); diff --git a/packages/kbn-management/settings/components/field_input/input/image_input.tsx b/packages/kbn-management/settings/components/field_input/input/image_input.tsx index b118c538e7b34..286cba2c49d4c 100644 --- a/packages/kbn-management/settings/components/field_input/input/image_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/image_input.tsx @@ -6,9 +6,12 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useImperativeHandle, useRef } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFilePicker, EuiImage } from '@elastic/eui'; +import { EuiFilePicker, EuiFilePickerProps, EuiImage } from '@elastic/eui'; + +import { ResetInputRef } from '@kbn/management-settings-types'; +import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities'; import type { InputProps } from '../types'; import { useServices } from '../services'; @@ -17,12 +20,7 @@ import { TEST_SUBJ_PREFIX_FIELD } from '.'; /** * Props for a {@link ImageInput} component. */ -export interface ImageInputProps extends InputProps<'image'> { - /** Indicate if the image has changed from the saved setting in the UI. */ - hasChanged: boolean; - /** Indicate if the image value is the default value in Kibana. */ - isDefaultValue: boolean; -} +export type ImageInputProps = InputProps<'image'>; const getImageAsBase64 = async (file: Blob): Promise => { const reader = new FileReader(); @@ -45,26 +43,21 @@ const errorMessage = i18n.translate('management.settings.field.imageChangeErrorM /** * Component for manipulating an `image` field. */ -export const ImageInput = React.forwardRef( - ( - { - ariaDescribedBy, - ariaLabel, - id, - isDisabled, - isDefaultValue, - onChange: onChangeProp, - name, - value, - hasChanged, - }, - ref - ) => { +export const ImageInput = React.forwardRef( + ({ field, unsavedChange, isSavingEnabled, onChange: onChangeProp }, ref) => { + const inputRef = useRef(null); + + useImperativeHandle(ref, () => ({ + reset: () => inputRef.current?.removeFiles(), + })); + const { showDanger } = useServices(); - const onChange = async (files: FileList | null) => { + const onUpdate = useUpdate({ onChange: onChangeProp, field }); + + const onChange: EuiFilePickerProps['onChange'] = async (files: FileList | null) => { if (files === null || !files.length) { - onChangeProp({ value: '' }); + onUpdate(); return null; } @@ -77,13 +70,17 @@ export const ImageInput = React.forwardRef( base64Image = String(await getImageAsBase64(file)); } - onChangeProp({ value: base64Image }); + onUpdate({ type: field.type, unsavedValue: base64Image }); } catch (err) { showDanger(errorMessage); - onChangeProp({ value: '', error: errorMessage }); + onUpdate({ type: field.type, unsavedValue: '', error: errorMessage, isInvalid: true }); } }; + const { id, name, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + const [value] = getFieldInputValue(field, unsavedChange); + const a11yProps = { 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedBy, @@ -91,16 +88,20 @@ export const ImageInput = React.forwardRef( // TODO: this check will be a bug, if a default image is ever actually // defined in Kibana. - if (value && !isDefaultValue && !hasChanged) { + // + // see: https://github.com/elastic/kibana/issues/166578 + // + if (value) { return ; } else { return ( ); } diff --git a/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx index 04108a3259738..2cd34de067ffc 100644 --- a/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import { CodeEditorInput } from './code_editor_input'; +import { CodeEditorInput, CodeEditorInputProps } from './code_editor_input'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; import { CodeEditorProps } from '../code_editor'; @@ -33,17 +33,25 @@ jest.mock('../code_editor', () => ({ })); describe('JsonEditorInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), - value: initialValue, - type: 'json' as 'json', + const onChange = jest.fn(); + const defaultProps: CodeEditorInputProps = { + onChange, + type: 'json', + field: { + name, + type: 'json', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: initialValue, + }, + isSavingEnabled: true, }; beforeEach(() => { - defaultProps.onChange.mockClear(); + onChange.mockClear(); }); it('renders without errors', () => { @@ -61,14 +69,17 @@ describe('JsonEditorInput', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '{"bar":"foo"}' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: '{"bar":"foo"}' }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ + type: 'json', + unsavedValue: '{"bar":"foo"}', + }); }); it('calls the onChange prop when the object value changes with no value', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: '{}' }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' }); }); it('calls the onChange prop with an error when the object value changes to invalid JSON', () => { @@ -76,7 +87,8 @@ describe('JsonEditorInput', () => { const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '{"bar" "foo"}' } }); expect(defaultProps.onChange).toHaveBeenCalledWith({ - value: '{"bar" "foo"}', + type: 'json', + unsavedValue: '{"bar" "foo"}', error: 'Invalid JSON syntax', isInvalid: true, }); @@ -88,7 +100,10 @@ describe('JsonEditorInput', () => { const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '["foo", "bar", "baz"]' } }); waitFor(() => - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: '["foo", "bar", "baz"]' }) + expect(defaultProps.onChange).toHaveBeenCalledWith({ + type: 'json', + unsavedValue: '["foo", "bar", "baz"]', + }) ); }); @@ -101,7 +116,7 @@ describe('JsonEditorInput', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: '[]' }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' }); }); it('calls the onChange prop with an array when the array value changes to invalid JSON', () => { @@ -110,7 +125,8 @@ describe('JsonEditorInput', () => { const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '["bar", "foo" | "baz"]' } }); expect(defaultProps.onChange).toHaveBeenCalledWith({ - value: '["bar", "foo" | "baz"]', + type: 'json', + unsavedValue: '["bar", "foo" | "baz"]', error: 'Invalid JSON syntax', isInvalid: true, }); diff --git a/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx index 4df09c3e5df71..dd15b250cf1e0 100644 --- a/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx @@ -9,18 +9,18 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; -import { CodeEditorInput } from './code_editor_input'; +import { CodeEditorInput, CodeEditorInputProps } from './code_editor_input'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; import { CodeEditorProps } from '../code_editor'; -const name = 'Some json field'; -const id = 'some:json:field'; +const name = 'Some markdown field'; +const id = 'some:markdown:field'; const initialValue = '# A Markdown Title'; jest.mock('../code_editor', () => ({ CodeEditor: ({ value, onChange }: CodeEditorProps) => ( { @@ -32,16 +32,28 @@ jest.mock('../code_editor', () => ({ ), })); -describe('JsonEditorInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), - value: initialValue, - type: 'markdown' as 'markdown', +describe('MarkdownEditorInput', () => { + const onChange = jest.fn(); + const defaultProps: CodeEditorInputProps = { + onChange, + type: 'markdown', + field: { + name, + type: 'markdown', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: initialValue, + }, + isSavingEnabled: true, }; + beforeEach(() => { + onChange.mockClear(); + }); + it('renders without errors', () => { const { container } = render(); expect(container).toBeInTheDocument(); @@ -57,6 +69,9 @@ describe('JsonEditorInput', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '# New Markdown Title' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: '# New Markdown Title' }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ + type: 'markdown', + unsavedValue: '# New Markdown Title', + }); }); }); diff --git a/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx index 2df3bbc96254f..3fd6518102a46 100644 --- a/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import { NumberInput } from './number_input'; +import { NumberInput, NumberInputProps } from './number_input'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; import { wrap } from '../mocks'; @@ -16,40 +16,72 @@ const name = 'Some number field'; const id = 'some:number:field'; describe('NumberInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), - value: 12345, + const onChange = jest.fn(); + const defaultProps: NumberInputProps = { + onChange, + field: { + name, + type: 'number', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: 12345, + }, + isSavingEnabled: true, }; + beforeEach(() => { + onChange.mockClear(); + }); + it('renders without errors', () => { - const { container } = render(wrap()); + const { container, getByTestId } = render(wrap()); expect(container).toBeInTheDocument(); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + expect(input).toHaveValue(defaultProps.field.defaultValue); }); - it('renders the value prop', () => { - const { getByTestId } = render(wrap()); + it('renders the saved value if present', () => { + const { getByTestId } = render( + wrap() + ); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + expect(input).toHaveValue(9876); + }); + + it('renders the unsaved value if present', () => { + const { getByTestId } = render( + wrap( + + ) + ); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); - expect(input).toHaveValue(defaultProps.value); + expect(input).toHaveValue(4321); }); it('calls the onChange prop when the value changes', () => { const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: '54321' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: 54321 }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'number', unsavedValue: 54321 }); }); it('disables the input when isDisabled prop is true', () => { - const { getByTestId } = render(wrap()); + const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); expect(input).toBeDisabled(); }); it('recovers if value is null', () => { - const { getByTestId } = render(wrap()); + const { getByTestId } = render( + wrap() + ); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); waitFor(() => expect(input).toHaveValue(undefined)); }); diff --git a/packages/kbn-management/settings/components/field_input/input/number_input.tsx b/packages/kbn-management/settings/components/field_input/input/number_input.tsx index 8d4862fa5e52e..f67e27be505e2 100644 --- a/packages/kbn-management/settings/components/field_input/input/number_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/number_input.tsx @@ -7,7 +7,10 @@ */ import React from 'react'; -import { EuiFieldNumber } from '@elastic/eui'; +import { EuiFieldNumber, EuiFieldNumberProps } from '@elastic/eui'; + +import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities'; + import { InputProps } from '../types'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; @@ -20,24 +23,23 @@ export type NumberInputProps = InputProps<'number'>; * Component for manipulating a `number` field. */ export const NumberInput = ({ - ariaDescribedBy, - ariaLabel, - id, - isDisabled: disabled = false, - name, + field, + unsavedChange, + isSavingEnabled, onChange: onChangeProp, - value: valueProp, }: NumberInputProps) => { - const onChange = (event: React.ChangeEvent) => - onChangeProp({ value: Number(event.target.value) }); + const onChange: EuiFieldNumberProps['onChange'] = (event) => { + const inputValue = Number(event.target.value); + onUpdate({ type: field.type, unsavedValue: inputValue }); + }; + + const onUpdate = useUpdate({ onChange: onChangeProp, field }); + + const { id, name, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + const [rawValue] = getFieldInputValue(field, unsavedChange); - // nit: we have to do this because, while the `UiSettingsService` might return - // `null`, the {@link EuiFieldNumber} component doesn't accept `null` as a - // value. - // - // @see packages/core/ui-settings/core-ui-settings-common/src/ui_settings.ts - // - const value = valueProp === null ? undefined : valueProp; + const value = rawValue === null ? undefined : rawValue; return ( ); }; diff --git a/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx index fe6fa934ab5bb..ca2e875a65604 100644 --- a/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx @@ -16,27 +16,35 @@ const name = 'Some select field'; const id = 'some:select:field'; describe('SelectInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), + const onChange = jest.fn(); + const defaultProps: SelectInputProps = { + onChange, + field: { + name, + type: 'select', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: 'option2', + }, optionLabels: { option1: 'Option 1', option2: 'Option 2', option3: 'Option 3', }, optionValues: ['option1', 'option2', 'option3'], - value: 'option2', + isSavingEnabled: true, }; - it('renders without errors', () => { - const { container } = render(wrap()); - expect(container).toBeInTheDocument(); + beforeEach(() => { + onChange.mockClear(); }); - it('renders the value prop', () => { - const { getByTestId } = render(wrap()); + it('renders without errors', () => { + const { container, getByTestId } = render(wrap()); + expect(container).toBeInTheDocument(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); expect(input).toHaveValue('option2'); }); @@ -45,11 +53,11 @@ describe('SelectInput', () => { const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: 'option3' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: 'option3' }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'select', unsavedValue: 'option3' }); }); it('disables the input when isDisabled prop is true', () => { - const { getByTestId } = render(wrap()); + const { getByTestId } = render(wrap()); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); expect(input).toBeDisabled(); }); diff --git a/packages/kbn-management/settings/components/field_input/input/select_input.tsx b/packages/kbn-management/settings/components/field_input/input/select_input.tsx index 4ca8fdf21532d..bd53fb9913ec5 100644 --- a/packages/kbn-management/settings/components/field_input/input/select_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/select_input.tsx @@ -7,7 +7,10 @@ */ import React, { useMemo } from 'react'; -import { EuiSelect } from '@elastic/eui'; +import { EuiSelect, EuiSelectProps } from '@elastic/eui'; + +import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities'; + import { InputProps } from '../types'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; @@ -25,14 +28,12 @@ export interface SelectInputProps extends InputProps<'select'> { * Component for manipulating a `select` field. */ export const SelectInput = ({ - ariaDescribedBy, - ariaLabel, - id, - isDisabled = false, + field, + unsavedChange, onChange: onChangeProp, optionLabels = {}, optionValues: optionsProp, - value: valueProp, + isSavingEnabled, }: SelectInputProps) => { if (optionsProp.length === 0) { throw new Error('non-empty `optionValues` are required for `SelectInput`.'); @@ -47,23 +48,23 @@ export const SelectInput = ({ [optionsProp, optionLabels] ); - const onChange = (event: React.ChangeEvent) => { - onChangeProp({ value: event.target.value }); + const onChange: EuiSelectProps['onChange'] = (event) => { + const inputValue = event.target.value; + onUpdate({ type: field.type, unsavedValue: inputValue }); }; - // nit: we have to do this because, while the `UiSettingsService` might return - // `null`, the {@link EuiSelect} component doesn't accept `null` as a value. - // - // @see packages/core/ui-settings/core-ui-settings-common/src/ui_settings.ts - // - const value = valueProp === null ? undefined : valueProp; + const onUpdate = useUpdate({ onChange: onChangeProp, field }); + + const { id, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + const [value] = getFieldInputValue(field, unsavedChange); return ( diff --git a/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx index d4dee9f32cdf6..9dcdb8a04d5ea 100644 --- a/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx +++ b/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx @@ -9,21 +9,33 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; -import { TextInput } from './text_input'; +import { TextInput, TextInputProps } from './text_input'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; const name = 'Some text field'; const id = 'some:text:field'; describe('TextInput', () => { - const defaultProps = { - id, - name, - ariaLabel: 'Test', - onChange: jest.fn(), - value: 'initial value', + const onChange = jest.fn(); + const defaultProps: TextInputProps = { + onChange, + field: { + name, + type: 'string', + ariaAttributes: { + ariaLabel: name, + }, + id, + isOverridden: false, + defaultValue: 'initial value', + }, + isSavingEnabled: true, }; + beforeEach(() => { + onChange.mockClear(); + }); + it('renders without errors', () => { const { container } = render(); expect(container).toBeInTheDocument(); @@ -39,11 +51,14 @@ describe('TextInput', () => { const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); fireEvent.change(input, { target: { value: 'new value' } }); - expect(defaultProps.onChange).toHaveBeenCalledWith({ value: 'new value' }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ + type: 'string', + unsavedValue: 'new value', + }); }); it('disables the input when isDisabled prop is true', () => { - const { getByTestId } = render(); + const { getByTestId } = render(); const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); expect(input).toBeDisabled(); }); diff --git a/packages/kbn-management/settings/components/field_input/input/text_input.tsx b/packages/kbn-management/settings/components/field_input/input/text_input.tsx index aa1dc913eeeea..f4f9450e9577f 100644 --- a/packages/kbn-management/settings/components/field_input/input/text_input.tsx +++ b/packages/kbn-management/settings/components/field_input/input/text_input.tsx @@ -7,7 +7,9 @@ */ import React from 'react'; -import { EuiFieldText } from '@elastic/eui'; +import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui'; + +import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities'; import { InputProps } from '../types'; import { TEST_SUBJ_PREFIX_FIELD } from '.'; @@ -21,23 +23,27 @@ export type TextInputProps = InputProps<'string'>; * Component for manipulating a `string` field. */ export const TextInput = ({ - name, + field, + unsavedChange, + isSavingEnabled, onChange: onChangeProp, - ariaLabel, - id, - isDisabled = false, - value: valueProp, - ariaDescribedBy, }: TextInputProps) => { - const value = valueProp || ''; - const onChange = (event: React.ChangeEvent) => - onChangeProp({ value: event.target.value }); + const onChange: EuiFieldTextProps['onChange'] = (event) => { + const inputValue = event.target.value; + onUpdate({ type: field.type, unsavedValue: inputValue }); + }; + + const onUpdate = useUpdate({ onChange: onChangeProp, field }); + + const { id, name, ariaAttributes } = field; + const { ariaLabel, ariaDescribedBy } = ariaAttributes; + const [value] = getFieldInputValue(field, unsavedChange); return ( | null> { - id: string; - ariaDescribedBy?: string; - ariaLabel: string; - isDisabled?: boolean; - isInvalid?: boolean; - value?: V; - name: string; +export interface InputProps { + field: Pick< + FieldDefinition, + 'ariaAttributes' | 'defaultValue' | 'id' | 'name' | 'savedValue' | 'type' | 'isOverridden' + >; + unsavedChange?: UnsavedFieldChange; + isSavingEnabled: boolean; /** The `onChange` handler. */ onChange: OnChangeFn; } - -/** - * Parameters for the {@link OnChangeFn} handler. - */ -export interface OnChangeParams { - /** The value provided to the handler. */ - value?: KnownTypeToValue | null; - /** An error message, if one occurred. */ - error?: string; - /** True if the format of a change is not valid, false otherwise. */ - isInvalid?: boolean; -} - -/** - * A function that is called when the value of a {@link FieldInput} changes. - * @param params The {@link OnChangeParams} parameters passed to the handler. - */ -export type OnChangeFn = (params: OnChangeParams) => void; diff --git a/packages/kbn-management/settings/components/field_row/__stories__/common.tsx b/packages/kbn-management/settings/components/field_row/__stories__/common.tsx index a18592ca867b2..58e145146cc40 100644 --- a/packages/kbn-management/settings/components/field_row/__stories__/common.tsx +++ b/packages/kbn-management/settings/components/field_row/__stories__/common.tsx @@ -6,21 +6,18 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState } from 'react'; import type { ComponentMeta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { EuiPanel } from '@elastic/eui'; -import { SettingType } from '@kbn/management-settings-types'; +import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; import { KnownTypeToMetadata, UiSettingMetadata } from '@kbn/management-settings-types/metadata'; -import { - useFieldDefinition, - getDefaultValue, - getUserValue, -} from '@kbn/management-settings-field-definition/storybook'; +import { getDefaultValue, getUserValue } from '@kbn/management-settings-utilities/storybook'; +import { getFieldDefinition } from '@kbn/management-settings-field-definition'; import { FieldRow as Component, FieldRow } from '../field_row'; import { FieldRowProvider } from '../services'; -import { OnChangeFn } from '../types'; +import { RowOnChangeFn } from '../types'; /** * Props for a {@link FieldInput} Storybook story. @@ -108,7 +105,7 @@ export const storyArgs = { */ export const getFieldRowStory = ( type: SettingType, - settingFields: Partial> + settingFields?: Partial> ) => { const Story = ({ isCustom, @@ -118,33 +115,61 @@ export const getFieldRowStory = ( userValue, value, }: StoryProps) => { + const [unsavedChange, setUnsavedChange] = useState< + UnsavedFieldChange | undefined + >(); + const setting: UiSettingMetadata = { type, value, - userValue, + userValue: userValue === '' ? null : userValue, name: `Some ${type} setting`, + deprecation: isDeprecated + ? { message: 'This setting is deprecated', docLinksKey: 'storybook' } + : undefined, + category: ['categoryOne', 'categoryTwo'], + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu odio velit. Integer et mauris quis ligula elementum commodo. Morbi eu ipsum diam. Nulla auctor orci eget egestas vehicula. Aliquam gravida, dolor eu posuere vulputate, neque enim viverra odio, id viverra ipsum quam et ipsum.', + requiresPageReload: false, ...settingFields, }; - const [field, unsavedChange, onChangeFn] = useFieldDefinition(setting, { - isCustom, - isDeprecated, - isOverridden, + const field = getFieldDefinition({ + id: setting.name?.split(' ').join(':').toLowerCase() || setting.type, + setting, + params: { + isCustom, + isOverridden, + }, }); - const onChange: OnChangeFn = (_key, change) => { - const { error, isInvalid, unsavedValue } = change; - onChangeFn({ error: error === null ? undefined : error, isInvalid, value: unsavedValue }); + const onChange: RowOnChangeFn = (_id, newChange) => { + setUnsavedChange(newChange); + + action('onChange')({ + type, + unsavedValue: newChange?.unsavedValue, + savedValue: field.savedValue, + }); }; return ; }; - Story.args = { - userValue: getUserValue(type), - value: getDefaultValue(type), - ...storyArgs, - }; + // In Kibana, the image default value is never anything other than null. There would be a number + // of issues if it was anything but, so, in Storybook, we want to remove the default value argument. + if (type === 'image') { + Story.args = { + userValue: getUserValue(type), + ...storyArgs, + }; + } else { + Story.args = { + userValue: getUserValue(type), + value: getDefaultValue(type), + ...storyArgs, + }; + } return Story; }; diff --git a/packages/kbn-management/settings/components/field_row/__stories__/select_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/select_field.stories.tsx index 299297f341282..531a708afc635 100644 --- a/packages/kbn-management/settings/components/field_row/__stories__/select_field.stories.tsx +++ b/packages/kbn-management/settings/components/field_row/__stories__/select_field.stories.tsx @@ -9,7 +9,7 @@ import { getFieldRowStory, getStory } from './common'; const argTypes = { - value: { + userValue: { name: 'Current saved value', control: { type: 'select', diff --git a/packages/kbn-management/settings/components/field_row/description/default_value.test.tsx b/packages/kbn-management/settings/components/field_row/description/default_value.test.tsx index 49bb85fb3cdcd..3071a4cd9ca0b 100644 --- a/packages/kbn-management/settings/components/field_row/description/default_value.test.tsx +++ b/packages/kbn-management/settings/components/field_row/description/default_value.test.tsx @@ -46,6 +46,28 @@ describe('FieldDefaultValue', () => { expect(container).toBeEmptyDOMElement(); }); + it('renders nothing if an unsaved change matches the default value', () => { + const { container } = render( + wrap( + + ) + ); + + expect(container).toBeEmptyDOMElement(); + }); + it('does not render a code block for string fields', () => { const { queryByTestId, getByText } = render( wrap( diff --git a/packages/kbn-management/settings/components/field_row/description/default_value.tsx b/packages/kbn-management/settings/components/field_row/description/default_value.tsx index 75fb9c4c7bdc4..535dbbdba8028 100644 --- a/packages/kbn-management/settings/components/field_row/description/default_value.tsx +++ b/packages/kbn-management/settings/components/field_row/description/default_value.tsx @@ -14,7 +14,7 @@ import { isJsonFieldDefinition, isMarkdownFieldDefinition, } from '@kbn/management-settings-field-definition'; -import { FieldDefinition, SettingType } from '@kbn/management-settings-types'; +import { FieldDefinition, SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; export const DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX = 'default-display-block'; /** @@ -22,18 +22,32 @@ export const DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX = 'default-display-block'; */ export interface FieldDefaultValueProps { /** The {@link FieldDefinition} corresponding the setting. */ - field: Pick, 'id' | 'type' | 'isDefaultValue' | 'defaultValueDisplay'>; + field: Pick< + FieldDefinition, + 'id' | 'type' | 'isDefaultValue' | 'defaultValueDisplay' | 'defaultValue' + >; + unsavedChange?: UnsavedFieldChange; } /** * Component for displaying the default value of a {@link FieldDefinition} * in the {@link FieldRow}. */ -export const FieldDefaultValue = ({ field }: FieldDefaultValueProps) => { +export const FieldDefaultValue = ({ + field, + unsavedChange, +}: FieldDefaultValueProps) => { if (field.isDefaultValue) { return null; } + if ( + unsavedChange && + (unsavedChange.unsavedValue === field.defaultValue || unsavedChange.unsavedValue === undefined) + ) { + return null; + } + const { defaultValueDisplay: display, id } = field; let value = {display}; diff --git a/packages/kbn-management/settings/components/field_row/description/deprecation.test.tsx b/packages/kbn-management/settings/components/field_row/description/deprecation.test.tsx index 73e70df48e48f..47b43ec7b085a 100644 --- a/packages/kbn-management/settings/components/field_row/description/deprecation.test.tsx +++ b/packages/kbn-management/settings/components/field_row/description/deprecation.test.tsx @@ -14,6 +14,7 @@ import { wrap } from '../mocks'; describe('FieldDeprecation', () => { const defaultProps = { field: { + id: 'test:field', name: 'test', type: 'string', deprecation: undefined, diff --git a/packages/kbn-management/settings/components/field_row/description/deprecation.tsx b/packages/kbn-management/settings/components/field_row/description/deprecation.tsx index 664f9e3e96047..76f8895df6603 100644 --- a/packages/kbn-management/settings/components/field_row/description/deprecation.tsx +++ b/packages/kbn-management/settings/components/field_row/description/deprecation.tsx @@ -5,13 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ import React from 'react'; import { EuiBadge, EuiToolTip } from '@elastic/eui'; diff --git a/packages/kbn-management/settings/components/field_row/description/description.test.tsx b/packages/kbn-management/settings/components/field_row/description/description.test.tsx index 859a530f3ccdd..92f66d797eb31 100644 --- a/packages/kbn-management/settings/components/field_row/description/description.test.tsx +++ b/packages/kbn-management/settings/components/field_row/description/description.test.tsx @@ -36,6 +36,19 @@ describe('FieldDescription', () => { expect(getByText(description)).toBeInTheDocument(); }); + it('renders a React Element', () => { + const value = 'This is a description.'; + const element =
{value}
; + const { getByText } = render( + wrap( + + ) + ); + expect(getByText(value)).toBeInTheDocument(); + }); + it('renders no description without one', () => { const { queryByText } = render(wrap()); expect(queryByText(description)).toBeNull(); diff --git a/packages/kbn-management/settings/components/field_row/description/description.tsx b/packages/kbn-management/settings/components/field_row/description/description.tsx index 86529f366a321..757cb22ba799c 100644 --- a/packages/kbn-management/settings/components/field_row/description/description.tsx +++ b/packages/kbn-management/settings/components/field_row/description/description.tsx @@ -75,7 +75,7 @@ export const FieldDescription = ({
{content} - +
); }; diff --git a/packages/kbn-management/settings/components/field_row/field_row.test.tsx b/packages/kbn-management/settings/components/field_row/field_row.test.tsx index 481cb43b6fcf9..afa425d2a459a 100644 --- a/packages/kbn-management/settings/components/field_row/field_row.test.tsx +++ b/packages/kbn-management/settings/components/field_row/field_row.test.tsx @@ -17,8 +17,8 @@ import { DATA_TEST_SUBJ_SCREEN_READER_MESSAGE, FieldRow } from './field_row'; import { wrap } from './mocks'; import { TEST_SUBJ_PREFIX_FIELD } from '@kbn/management-settings-components-field-input/input'; -import { DATA_TEST_SUBJ_OVERRIDDEN_PREFIX } from './input_footer/overridden_message'; -import { DATA_TEST_SUBJ_RESET_PREFIX } from './input_footer/reset_link'; +import { DATA_TEST_SUBJ_RESET_PREFIX } from './footer/reset_link'; +import { DATA_TEST_SUBJ_CHANGE_LINK_PREFIX } from './footer/change_image_link'; const defaults = { requiresPageReload: false, @@ -87,7 +87,7 @@ const settings: Omit = { description: 'Description for Array test setting', name: 'array:test:setting', type: 'array', - userValue: undefined, + userValue: null, value: defaultValues.array, ...defaults, }, @@ -95,7 +95,7 @@ const settings: Omit = { description: 'Description for Boolean test setting', name: 'boolean:test:setting', type: 'boolean', - userValue: undefined, + userValue: null, value: defaultValues.boolean, ...defaults, }, @@ -103,7 +103,7 @@ const settings: Omit = { description: 'Description for Color test setting', name: 'color:test:setting', type: 'color', - userValue: undefined, + userValue: null, value: defaultValues.color, ...defaults, }, @@ -111,7 +111,7 @@ const settings: Omit = { description: 'Description for Image test setting', name: 'image:test:setting', type: 'image', - userValue: undefined, + userValue: null, value: defaultValues.image, ...defaults, }, @@ -132,7 +132,7 @@ const settings: Omit = { // name: 'markdown:test:setting', // description: 'Description for Markdown test setting', // type: 'markdown', - // userValue: undefined, + // userValue: null, // value: '', // ...defaults, // }, @@ -140,7 +140,7 @@ const settings: Omit = { description: 'Description for Number test setting', name: 'number:test:setting', type: 'number', - userValue: undefined, + userValue: null, value: defaultValues.number, ...defaults, }, @@ -154,7 +154,7 @@ const settings: Omit = { banana: 'Banana', }, type: 'select', - userValue: undefined, + userValue: null, value: defaultValues.select, ...defaults, }, @@ -162,7 +162,7 @@ const settings: Omit = { description: 'Description for String test setting', name: 'string:test:setting', type: 'string', - userValue: undefined, + userValue: null, value: defaultValues.string, ...defaults, }, @@ -170,7 +170,7 @@ const settings: Omit = { description: 'Description for Undefined test setting', name: 'undefined:test:setting', type: 'undefined', - userValue: undefined, + userValue: null, value: defaultValues.undefined, ...defaults, }, @@ -254,7 +254,7 @@ describe('Field', () => { expect(getByTestId(inputTestSubj)).toBeDisabled(); } - expect(getByTestId(`${DATA_TEST_SUBJ_OVERRIDDEN_PREFIX}-${id}`)).toBeInTheDocument(); + // expect(getByTestId(`${DATA_TEST_SUBJ_OVERRIDDEN_PREFIX}-${id}`)).toBeInTheDocument(); }); it('should render as read only if saving is disabled', () => { @@ -383,6 +383,28 @@ describe('Field', () => { unsavedValue: field.defaultValue, }); }); + + it('should reset when reset link is clicked with an unsaved change', () => { + const field = getFieldDefinition({ + id, + setting, + }); + + const { getByTestId } = render( + wrap( + + ) + ); + + const input = getByTestId(`${DATA_TEST_SUBJ_RESET_PREFIX}-${field.id}`); + fireEvent.click(input); + expect(handleChange).toHaveBeenCalledWith(field.id, undefined); + }); }); }); @@ -442,7 +464,9 @@ describe('Field', () => { expect(getByText('Setting is currently not saved.')).toBeInTheDocument(); const input = getByTestId(`euiColorPickerAnchor ${TEST_SUBJ_PREFIX_FIELD}-${field.id}`); fireEvent.change(input, { target: { value: '#1235' } }); + waitFor(() => expect(input).toHaveValue('#1235')); + waitFor(() => expect(getByTestId(`${DATA_TEST_SUBJ_SCREEN_READER_MESSAGE}-${field.id}`)).toBe( 'Provide a valid color value' @@ -473,9 +497,52 @@ describe('Field', () => { const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${field.id}`); fireEvent.change(input, { target: { value: field.savedValue } }); - expect(handleChange).toHaveBeenCalledWith(field.id, { - type: 'string', - unsavedValue: undefined, + expect(handleChange).toHaveBeenCalledWith(field.id, undefined); + }); + + it('should clear the current image when Change Image is clicked', () => { + const setting = settings.image; + + const field = getFieldDefinition({ + id: setting.name || setting.type, + setting: { + ...setting, + userValue: userInputValues.image, + }, }); + + const { getByTestId, getByAltText } = render( + wrap() + ); + + const link = getByTestId(`${DATA_TEST_SUBJ_CHANGE_LINK_PREFIX}-${field.id}`); + fireEvent.click(link); + waitFor(() => expect(getByAltText(field.id)).not.toBeInTheDocument()); + }); + + it('should clear the unsaved image when Change Image is clicked', () => { + const setting = settings.image; + + const field = getFieldDefinition({ + id: setting.name || setting.type, + setting: { + ...setting, + }, + }); + + const { getByTestId, getByAltText } = render( + wrap( + + ) + ); + + const link = getByTestId(`${DATA_TEST_SUBJ_CHANGE_LINK_PREFIX}-${field.id}`); + fireEvent.click(link); + waitFor(() => expect(getByAltText(field.id)).not.toBeInTheDocument()); }); }); diff --git a/packages/kbn-management/settings/components/field_row/field_row.tsx b/packages/kbn-management/settings/components/field_row/field_row.tsx index c7f90af8c90fd..9058511c955d1 100644 --- a/packages/kbn-management/settings/components/field_row/field_row.tsx +++ b/packages/kbn-management/settings/components/field_row/field_row.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useRef } from 'react'; import { EuiScreenReaderOnly, @@ -18,101 +18,112 @@ import { i18n } from '@kbn/i18n'; import type { FieldDefinition, + ResetInputRef, SettingType, UnsavedFieldChange, + OnChangeFn, } from '@kbn/management-settings-types'; import { isImageFieldDefinition } from '@kbn/management-settings-field-definition'; -import { FieldInput, type OnChangeParams } from '@kbn/management-settings-components-field-input'; -import { isUnsavedValue } from '@kbn/management-settings-utilities'; +import { FieldInput } from '@kbn/management-settings-components-field-input'; +import { hasUnsavedChange } from '@kbn/management-settings-utilities'; import { FieldDescription } from './description'; import { FieldTitle } from './title'; -import { FieldInputFooter } from './input_footer'; import { useFieldStyles } from './field_row.styles'; -import { OnChangeFn } from './types'; +import { RowOnChangeFn } from './types'; +import { FieldInputFooter } from './footer'; export const DATA_TEST_SUBJ_SCREEN_READER_MESSAGE = 'fieldRowScreenReaderMessage'; +type Definition = Pick< + FieldDefinition, + | 'ariaAttributes' + | 'defaultValue' + | 'defaultValueDisplay' + | 'displayName' + | 'groupId' + | 'id' + | 'isCustom' + | 'isDefaultValue' + | 'isOverridden' + | 'name' + | 'savedValue' + | 'type' + | 'unsavedFieldId' +>; + /** * Props for a {@link FieldRow} component. */ -export interface FieldRowProps { +export interface FieldRowProps { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Definition; /** True if saving settings is enabled, false otherwise. */ isSavingEnabled: boolean; /** The {@link OnChangeFn} handler. */ - onChange: OnChangeFn; + onChange: RowOnChangeFn; /** * The onClear handler, if a value is cleared to an empty or default state. * @param id The id relating to the field to clear. */ onClear?: (id: string) => void; - /** The {@link FieldDefinition} corresponding the setting. */ - field: FieldDefinition; /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ - unsavedChange?: UnsavedFieldChange; + unsavedChange?: UnsavedFieldChange; } /** * Component for displaying a {@link FieldDefinition} in a form row, using a {@link FieldInput}. * @param props The {@link FieldRowProps} for the {@link FieldRow} component. */ -export const FieldRow = (props: FieldRowProps) => { +export const FieldRow = (props: FieldRowProps) => { const { isSavingEnabled, onChange: onChangeProp, field, unsavedChange } = props; - const { id, name, groupId, isOverridden, type, unsavedFieldId } = field; + const { id, groupId, isOverridden, unsavedFieldId } = field; const { cssFieldFormGroup } = useFieldStyles({ field, unsavedChange, }); - const onChange = (changes: UnsavedFieldChange) => { - onChangeProp(name, changes); - }; + // Create a ref for those input fields that use a `reset` handle. + const ref = useRef(null); - const resetField = () => { - const { defaultValue: unsavedValue } = field; - return onChange({ type, unsavedValue }); + // Route any change to the `onChange` handler, along with the field id. + const onChange: OnChangeFn = (update) => { + onChangeProp(id, update); }; - const onFieldChange = ({ isInvalid, error, value: unsavedValue }: OnChangeParams) => { - if (error) { - isInvalid = true; + const onReset = () => { + ref.current?.reset(); + + const update = { type: field.type, unsavedValue: field.defaultValue }; + + if (hasUnsavedChange(field, update)) { + onChange(update); + } else { + onChange(); } + }; - const change = { - type, - isInvalid, - error, - }; + const onClear = () => { + if (ref.current) { + ref.current.reset(); + } - if (!isUnsavedValue(field, unsavedValue)) { - onChange(change); + // Indicate a field is being cleared for a new value by setting its unchanged + // value to`undefined`. Currently, this only applies to `image` fields. + if (field.savedValue !== undefined && field.savedValue !== null) { + onChange({ type: field.type, unsavedValue: undefined }); } else { - onChange({ - ...change, - unsavedValue, - }); + onChange(); } }; const title = ; - const description = ; + const description = ; const error = unsavedChange?.error; const isInvalid = unsavedChange?.isInvalid; let unsavedScreenReaderMessage = null; - const helpText = ( - - ); - + // Provide a screen-reader only message if there's an unsaved change. if (unsavedChange) { unsavedScreenReaderMessage = ( @@ -133,23 +144,25 @@ export const FieldRow = (props: FieldRowProps) => { return ( + } + {...{ isInvalid, error }} > <> {unsavedScreenReaderMessage} diff --git a/packages/kbn-management/settings/components/field_row/footer/change_image_link.test.tsx b/packages/kbn-management/settings/components/field_row/footer/change_image_link.test.tsx new file mode 100644 index 0000000000000..201942bc89d44 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/footer/change_image_link.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { ChangeImageLink, type ChangeImageLinkProps } from './change_image_link'; +import { wrap } from '../mocks'; + +const IMAGE = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAADMElEQVR4nOzVwQnAIBQFQYXff81RUkQCOyDj1YOPnbXWPmeTRef+/3O/OyBjzh3CD95BfqICMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMO0TAAD//2Anhf4QtqobAAAAAElFTkSuQmCC +`; + +describe('ChangeImageLink', () => { + const defaultProps: ChangeImageLinkProps = { + field: { + name: 'test', + type: 'image', + ariaAttributes: { + ariaLabel: 'test', + }, + isOverridden: false, + savedValue: null, + }, + unsavedChange: undefined, + onClear: jest.fn(), + }; + + it('does not render with no saved value and no unsaved change', () => { + const { container } = render(wrap()); + expect(container.firstChild).toBeNull(); + }); + + it('renders with a saved value and no unsaved change', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).not.toBeNull(); + }); + + it('does not render if there is a saved value and the unsaved value is undefined', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders when there is an unsaved change', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).not.toBeNull(); + }); + + it('renders nothing if the unsaved change value is undefined', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders an aria-label', () => { + const { getByLabelText } = render( + wrap( + + ) + ); + const link = getByLabelText('Change test'); + expect(link).not.toBeNull(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.tsx b/packages/kbn-management/settings/components/field_row/footer/change_image_link.tsx similarity index 51% rename from packages/kbn-management/settings/components/field_row/input_footer/change_image_link.tsx rename to packages/kbn-management/settings/components/field_row/footer/change_image_link.tsx index c4e6df6b4521b..78fedea1c8644 100644 --- a/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.tsx +++ b/packages/kbn-management/settings/components/field_row/footer/change_image_link.tsx @@ -13,26 +13,22 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { FieldDefinition, SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; import { hasUnsavedChange } from '@kbn/management-settings-utilities'; -import { OnChangeFn } from '@kbn/management-settings-components-field-input'; -import { - isImageFieldDefinition, - isImageFieldUnsavedChange, -} from '@kbn/management-settings-field-definition'; + +export const DATA_TEST_SUBJ_CHANGE_LINK_PREFIX = 'management-settings-change-image'; type Field = Pick< FieldDefinition, - 'name' | 'defaultValue' | 'type' | 'savedValue' | 'savedValue' | 'ariaAttributes' + 'id' | 'type' | 'savedValue' | 'ariaAttributes' | 'isOverridden' >; + /** * Props for a {@link ChangeImageLink} component. */ export interface ChangeImageLinkProps { /** The {@link ImageFieldDefinition} corresponding the setting. */ field: Field; - /** The {@link OnChangeFn} event handler. */ - onChange: OnChangeFn; - /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ unsavedChange?: UnsavedFieldChange; + onClear: () => void; } /** @@ -41,46 +37,49 @@ export interface ChangeImageLinkProps { */ export const ChangeImageLink = ({ field, - onChange, + onClear, unsavedChange, }: ChangeImageLinkProps) => { - if (hasUnsavedChange(field, unsavedChange)) { + if (field.type !== 'image') { return null; } - const { unsavedValue } = unsavedChange || {}; const { - savedValue, ariaAttributes: { ariaLabel }, - name, - defaultValue, + isOverridden, + savedValue, } = field; - if (unsavedValue || !savedValue) { + if ( + // If the field is overridden... + isOverridden || + // ... or if there's a saved value but no unsaved change... + (!savedValue && !hasUnsavedChange(field, unsavedChange)) || + // ... or if there's a saved value and an undefined unsaved value... + (savedValue && !!unsavedChange && unsavedChange.unsavedValue === undefined) + ) { + // ...don't render the link. return null; } - if (isImageFieldDefinition(field) && isImageFieldUnsavedChange(unsavedChange)) { - return ( - - onChange({ value: defaultValue })} - data-test-subj={`management-settings-changeImage-${name}`} - > - - - - ); - } - - return null; + // Use the type-guards on the definition and unsaved change. + return ( + + onClear()} + data-test-subj={`${DATA_TEST_SUBJ_CHANGE_LINK_PREFIX}-${field.id}`} + > + + + + ); }; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/index.ts b/packages/kbn-management/settings/components/field_row/footer/index.ts similarity index 85% rename from packages/kbn-management/settings/components/field_row/input_footer/index.ts rename to packages/kbn-management/settings/components/field_row/footer/index.ts index d840b892b9bd8..0322c72010380 100644 --- a/packages/kbn-management/settings/components/field_row/input_footer/index.ts +++ b/packages/kbn-management/settings/components/field_row/footer/index.ts @@ -7,3 +7,4 @@ */ export { FieldInputFooter, type FieldInputFooterProps } from './input_footer'; +export { InputResetLink, type FieldResetLinkProps } from './reset_link'; diff --git a/packages/kbn-management/settings/components/field_row/footer/input_footer.styles.ts b/packages/kbn-management/settings/components/field_row/footer/input_footer.styles.ts new file mode 100644 index 0000000000000..81db664b55bf0 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/footer/input_footer.styles.ts @@ -0,0 +1,28 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +/** + * A React hook that provides stateful `css` classes for the {@link FieldRow} component. + */ +export const useInputFooterStyles = () => { + const { + euiTheme: { size }, + } = useEuiTheme(); + + return { + footerCSS: css` + margin-top: ${size.s}; + > * { + margin-right: ${size.s}; + } + `, + }; +}; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/input_footer.tsx b/packages/kbn-management/settings/components/field_row/footer/input_footer.tsx similarity index 77% rename from packages/kbn-management/settings/components/field_row/input_footer/input_footer.tsx rename to packages/kbn-management/settings/components/field_row/footer/input_footer.tsx index 5a2e12f39f6b2..cd931ad3ae55c 100644 --- a/packages/kbn-management/settings/components/field_row/input_footer/input_footer.tsx +++ b/packages/kbn-management/settings/components/field_row/footer/input_footer.tsx @@ -14,11 +14,10 @@ import type { UnsavedFieldChange, } from '@kbn/management-settings-types'; -import { OnChangeFn } from '@kbn/management-settings-components-field-input'; - -import { FieldResetLink } from './reset_link'; +import { InputResetLink } from './reset_link'; import { ChangeImageLink } from './change_image_link'; import { FieldOverriddenMessage } from './overridden_message'; +import { useInputFooterStyles } from './input_footer.styles'; export const DATA_TEST_SUBJ_FOOTER_PREFIX = 'field-row-input-footer'; @@ -35,8 +34,8 @@ export interface FieldInputFooterProps { field: Field; /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ unsavedChange?: UnsavedFieldChange; - /** The {@link OnChangeFn} handler. */ - onChange: OnChangeFn; + /** A handler for clearing, rather than resetting the field. */ + onClear: () => void; /** A handler for when a field is reset to its default or saved value. */ onReset: () => void; /** True if saving this setting is enabled, false otherwise. */ @@ -44,20 +43,23 @@ export interface FieldInputFooterProps { } export const FieldInputFooter = ({ - isSavingEnabled, field, + isSavingEnabled, + onClear, onReset, - ...props + unsavedChange, }: FieldInputFooterProps) => { + const { footerCSS } = useInputFooterStyles(); + if (field.isOverridden) { return ; } if (isSavingEnabled) { return ( - - - + + + ); } diff --git a/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.test.tsx b/packages/kbn-management/settings/components/field_row/footer/overridden_message.test.tsx similarity index 100% rename from packages/kbn-management/settings/components/field_row/input_footer/overridden_message.test.tsx rename to packages/kbn-management/settings/components/field_row/footer/overridden_message.test.tsx diff --git a/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.tsx b/packages/kbn-management/settings/components/field_row/footer/overridden_message.tsx similarity index 100% rename from packages/kbn-management/settings/components/field_row/input_footer/overridden_message.tsx rename to packages/kbn-management/settings/components/field_row/footer/overridden_message.tsx diff --git a/packages/kbn-management/settings/components/field_row/footer/reset_link.test.tsx b/packages/kbn-management/settings/components/field_row/footer/reset_link.test.tsx new file mode 100644 index 0000000000000..2704e9e33d743 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/footer/reset_link.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { SettingType } from '@kbn/management-settings-types'; + +import { wrap } from '../mocks'; +import { InputResetLink, InputResetLinkProps } from './reset_link'; + +describe('InputResetLink', () => { + const defaultProps: InputResetLinkProps = { + field: { + type: 'string', + id: 'test', + isOverridden: false, + ariaAttributes: { + ariaLabel: 'Test', + }, + defaultValue: 'default', + }, + onReset: jest.fn(), + }; + + it('renders nothing if the field is already at its default value', () => { + const { container } = render(wrap()); + expect(container.firstChild).toBeNull(); + }); + + it('renders a link to reset the field if there is a different saved value', () => { + const { getByText } = render( + wrap( + + ) + ); + const link = getByText('Reset to default'); + expect(link).toBeInTheDocument(); + }); + + it('renders a link to reset the field if there is a different unsaved value', () => { + const { getByText } = render( + wrap( + + ) + ); + const link = getByText('Reset to default'); + expect(link).toBeInTheDocument(); + }); + + it('renders nothing if there is a different saved value but the same unsaved value', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).toBeNull(); + }); + + it('calls the onReset prop when the link is clicked', () => { + const { getByText } = render( + wrap( + + ) + ); + const link = getByText('Reset to default'); + fireEvent.click(link); + expect(defaultProps.onReset).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/footer/reset_link.tsx b/packages/kbn-management/settings/components/field_row/footer/reset_link.tsx new file mode 100644 index 0000000000000..35a34350e0be8 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/footer/reset_link.tsx @@ -0,0 +1,72 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { + FieldDefinition, + SettingType, + UnsavedFieldChange, +} from '@kbn/management-settings-types'; +import { isFieldDefaultValue } from '@kbn/management-settings-utilities'; + +/** + * Props for a {@link InputResetLink} component. + */ +export interface InputResetLinkProps { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Pick< + FieldDefinition, + 'ariaAttributes' | 'id' | 'savedValue' | 'isOverridden' | 'defaultValue' | 'type' + >; + /** A handler for when a field is reset to its default or saved value. */ + onReset: () => void; + /** A change to the current field, if any. */ + unsavedChange?: UnsavedFieldChange; +} + +export const DATA_TEST_SUBJ_RESET_PREFIX = 'management-settings-resetField'; +/** + * Component for rendering a link to reset a {@link FieldDefinition} to its default + * or saved value. + */ +export const InputResetLink = ({ + onReset: onClick, + field, + unsavedChange, +}: InputResetLinkProps) => { + if (isFieldDefaultValue(field, unsavedChange) || field.isOverridden) { + return null; + } + + const { + id, + ariaAttributes: { ariaLabel }, + } = field; + + return ( + + + + ); +}; diff --git a/packages/kbn-management/settings/components/field_row/index.ts b/packages/kbn-management/settings/components/field_row/index.ts index f54eadd4467ed..98c64f1cd494d 100644 --- a/packages/kbn-management/settings/components/field_row/index.ts +++ b/packages/kbn-management/settings/components/field_row/index.ts @@ -7,3 +7,11 @@ */ export { FieldRow, type FieldRowProps as FieldProps } from './field_row'; +export { FieldRowProvider, FieldRowKibanaProvider, type FieldRowProviderProps } from './services'; +export type { + FieldRowServices, + FieldRowKibanaDependencies, + RowOnChangeFn, + KibanaDependencies, + Services, +} from './types'; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.test.tsx b/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.test.tsx deleted file mode 100644 index 3c01240a9e9ea..0000000000000 --- a/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import React from 'react'; -import { render } from '@testing-library/react'; -import { ChangeImageLink } from './change_image_link'; -import { ImageFieldDefinition } from '@kbn/management-settings-types'; -import { wrap } from '../mocks'; -import { IMAGE } from '@kbn/management-settings-field-definition/storybook'; - -describe('ChangeImageLink', () => { - const defaultProps = { - field: { - name: 'test', - type: 'image', - ariaAttributes: { - ariaLabel: 'test', - }, - } as ImageFieldDefinition, - onChange: jest.fn(), - onCancel: jest.fn(), - onReset: jest.fn(), - unsavedChange: undefined, - }; - - it('does not render no saved value and no unsaved change', () => { - const { container } = render( - wrap() - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders with a saved value and no unsaved change', () => { - const { container } = render( - wrap( - - ) - ); - expect(container.firstChild).not.toBeNull(); - }); - - it('renders if there is a saved value and the unsaved value is undefined', () => { - const { container } = render( - wrap( - - ) - ); - expect(container.firstChild).not.toBeNull(); - }); - - it('renders nothing when there is an unsaved change', () => { - const { container } = render( - wrap( - - ) - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders an aria-label', () => { - const { getByLabelText } = render( - wrap( - - ) - ); - const link = getByLabelText('Change test'); - expect(link).not.toBeNull(); - }); -}); diff --git a/packages/kbn-management/settings/components/field_row/input_footer/reset_link.test.tsx b/packages/kbn-management/settings/components/field_row/input_footer/reset_link.test.tsx deleted file mode 100644 index 52cf165ab9b9f..0000000000000 --- a/packages/kbn-management/settings/components/field_row/input_footer/reset_link.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; - -import { FieldDefinition } from '@kbn/management-settings-types'; - -import { wrap } from '../mocks'; -import { FieldResetLink } from './reset_link'; - -describe('FieldResetLink', () => { - const defaultProps = { - field: { - name: 'test', - type: 'string', - isDefaultValue: false, - ariaAttributes: {}, - } as FieldDefinition<'string'>, - onReset: jest.fn(), - }; - - it('renders without errors', () => { - const { container } = render(wrap()); - expect(container).toBeInTheDocument(); - }); - - it('renders nothing if the field is already at its default value', () => { - const { container } = render( - wrap( - - ) - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders a link to reset the field if it is not at its default value', () => { - const { getByText } = render(wrap()); - const link = getByText('Reset to default'); - expect(link).toBeInTheDocument(); - }); - - it('calls the onReset prop when the link is clicked', () => { - const { getByText } = render(wrap()); - const link = getByText('Reset to default'); - fireEvent.click(link); - expect(defaultProps.onReset).toHaveBeenCalled(); - }); -}); diff --git a/packages/kbn-management/settings/components/field_row/input_footer/reset_link.tsx b/packages/kbn-management/settings/components/field_row/input_footer/reset_link.tsx deleted file mode 100644 index 2703a4121107d..0000000000000 --- a/packages/kbn-management/settings/components/field_row/input_footer/reset_link.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiLink } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { FieldDefinition, SettingType } from '@kbn/management-settings-types'; - -/** - * Props for a {@link FieldResetLink} component. - */ -export interface FieldResetLinkProps { - /** The {@link FieldDefinition} corresponding the setting. */ - field: Pick, 'id' | 'isDefaultValue' | 'ariaAttributes'>; - /** A handler for when a field is reset to its default or saved value. */ - onReset: () => void; -} - -export const DATA_TEST_SUBJ_RESET_PREFIX = 'management-settings-resetField'; -/** - * Component for rendering a link to reset a {@link FieldDefinition} to its default - * or saved value. - */ -export const FieldResetLink = ({ - onReset, - field, -}: FieldResetLinkProps) => { - if (field.isDefaultValue) { - return null; - } - - const { - id, - ariaAttributes: { ariaLabel }, - } = field; - - return ( - - - - -     - - ); -}; diff --git a/packages/kbn-management/settings/components/field_row/kibana.jsonc b/packages/kbn-management/settings/components/field_row/kibana.jsonc index ceec221d6a2d2..f931d719df741 100644 --- a/packages/kbn-management/settings/components/field_row/kibana.jsonc +++ b/packages/kbn-management/settings/components/field_row/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", "id": "@kbn/management-settings-components-field-row", - "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" -} + "owner": "@elastic/platform-deployment-management" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/components/field_row/services.tsx b/packages/kbn-management/settings/components/field_row/services.tsx index 7d9fab6d87035..e138307979db1 100644 --- a/packages/kbn-management/settings/components/field_row/services.tsx +++ b/packages/kbn-management/settings/components/field_row/services.tsx @@ -17,9 +17,16 @@ import type { FieldRowServices, FieldRowKibanaDependencies, Services } from './t const FieldRowContext = React.createContext(null); /** - * React Provider that provides services to a {@link FieldRow} component and its dependents. + * Props for {@link FieldRowProvider}. */ -export const FieldRowProvider: FC = ({ children, ...services }) => { +export interface FieldRowProviderProps extends FieldRowServices { + children: React.ReactNode; +} + +/** + * React Provider that provides services to a {@link FieldRow} component and its dependents.\ + */ +export const FieldRowProvider = ({ children, ...services }: FieldRowProviderProps) => { // Typescript types are widened to accept more than what is needed. Take only what is necessary // so the context remains clean. const { links, showDanger } = services; diff --git a/packages/kbn-management/settings/components/field_row/title/title.tsx b/packages/kbn-management/settings/components/field_row/title/title.tsx index 36c6042394287..dded57a3d7a87 100644 --- a/packages/kbn-management/settings/components/field_row/title/title.tsx +++ b/packages/kbn-management/settings/components/field_row/title/title.tsx @@ -22,7 +22,10 @@ import { FieldTitleUnsavedIcon } from './icon_unsaved'; */ export interface TitleProps { /** The {@link FieldDefinition} corresponding the setting. */ - field: FieldDefinition; + field: Pick< + FieldDefinition, + 'displayName' | 'savedValue' | 'isCustom' | 'id' | 'type' | 'isOverridden' + >; /** Emotion-based `css` for the root React element. */ css?: Interpolation; /** Classname for the root React element. */ diff --git a/packages/kbn-management/settings/components/field_row/types.ts b/packages/kbn-management/settings/components/field_row/types.ts index 9eec1eb234f2c..d353cd91fba49 100644 --- a/packages/kbn-management/settings/components/field_row/types.ts +++ b/packages/kbn-management/settings/components/field_row/types.ts @@ -49,4 +49,7 @@ export type FieldRowKibanaDependencies = KibanaDependencies & FieldInputKibanaDe * @param id A unique id corresponding to the particular setting being changed. * @param change The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ -export type OnChangeFn = (id: string, change: UnsavedFieldChange) => void; +export type RowOnChangeFn = ( + id: string, + change?: UnsavedFieldChange +) => void; diff --git a/packages/kbn-management/settings/field_definition/get_definition.ts b/packages/kbn-management/settings/field_definition/get_definition.ts index e6b29e6f437ca..8e7204b137693 100644 --- a/packages/kbn-management/settings/field_definition/get_definition.ts +++ b/packages/kbn-management/settings/field_definition/get_definition.ts @@ -14,11 +14,11 @@ */ import words from 'lodash/words'; -import isEqual from 'lodash/isEqual'; import { Query } from '@elastic/eui'; import { FieldDefinition, SettingType } from '@kbn/management-settings-types'; -import { UiSettingMetadata } from '@kbn/management-settings-types/metadata'; +import { UiSettingMetadata } from '@kbn/management-settings-types'; +import { isSettingDefaultValue } from '@kbn/management-settings-utilities'; /** * The portion of the setting name that defines the category of the setting. @@ -39,6 +39,10 @@ const mapWords = (name?: string): string => * Derive the aria-label for a given setting based on its name and category. */ const getAriaLabel = (name: string = '') => { + if (!name) { + return ''; + } + const query = Query.parse(name); if (query.hasOrFieldClause(CATEGORY_FIELD)) { @@ -121,7 +125,7 @@ export const getFieldDefinition = ( const definition: FieldDefinition = { ariaAttributes: { - ariaLabel: getAriaLabel(name), + ariaLabel: name || getAriaLabel(name), // ariaDescribedBy: unsavedChange.value ? `${groupId} ${unsavedId}` : undefined, }, categories, @@ -133,7 +137,7 @@ export const getFieldDefinition = ( groupId: `${name || id}-group`, id, isCustom: isCustom || false, - isDefaultValue: isEqual(defaultValue, setting.userValue), + isDefaultValue: isSettingDefaultValue(setting), isOverridden: isOverridden || false, isReadOnly: !!readonly, metric, diff --git a/packages/kbn-management/settings/field_definition/get_definitions.ts b/packages/kbn-management/settings/field_definition/get_definitions.ts new file mode 100644 index 0000000000000..c42613c8c2ce1 --- /dev/null +++ b/packages/kbn-management/settings/field_definition/get_definitions.ts @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { FieldDefinition, SettingType, UiSettingMetadata } from '@kbn/management-settings-types'; +import { getFieldDefinition } from './get_definition'; + +/** + * Convenience function to convert settings taken from a UiSettingsClient into + * {@link FieldDefinition} objects. + * + * @param settings The settings retreived from the UiSettingsClient. + * @param client The client itself, used to determine if a setting is custom or overridden. + * @returns An array of {@link FieldDefinition} objects. + */ +export const getFieldDefinitions = ( + settings: Record>, + client: IUiSettingsClient +): Array> => + Object.entries(settings).map(([id, setting]) => + getFieldDefinition({ + id, + setting, + params: { isCustom: client.isCustom(id), isOverridden: client.isOverridden(id) }, + }) + ); diff --git a/packages/kbn-management/settings/field_definition/index.ts b/packages/kbn-management/settings/field_definition/index.ts index 2cd44db7df3b4..0c39e349ec3cf 100644 --- a/packages/kbn-management/settings/field_definition/index.ts +++ b/packages/kbn-management/settings/field_definition/index.ts @@ -30,3 +30,4 @@ export { } from './is'; export { getFieldDefinition } from './get_definition'; +export { getFieldDefinitions } from './get_definitions'; diff --git a/packages/kbn-management/settings/field_definition/kibana.jsonc b/packages/kbn-management/settings/field_definition/kibana.jsonc index 687f04662bbe4..43e2f19c5363f 100644 --- a/packages/kbn-management/settings/field_definition/kibana.jsonc +++ b/packages/kbn-management/settings/field_definition/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", "id": "@kbn/management-settings-field-definition", - "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" -} + "owner": "@elastic/platform-deployment-management" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/field_definition/storybook/field_definition.ts b/packages/kbn-management/settings/field_definition/storybook/field_definition.ts deleted file mode 100644 index 022b2e3e98050..0000000000000 --- a/packages/kbn-management/settings/field_definition/storybook/field_definition.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { useState } from 'react'; -import isEqual from 'lodash/isEqual'; - -import { action } from '@storybook/addon-actions'; - -import type { - FieldDefinition, - KnownTypeToValue, - SettingType, - UnsavedFieldChange, -} from '@kbn/management-settings-types'; - -import { UiSettingMetadata } from '@kbn/management-settings-types/metadata'; -import { getFieldDefinition } from '../get_definition'; - -/** - * Expand a typed {@link UiSettingMetadata} object with common {@link UiSettingMetadata} properties. - */ -const expandSetting = ( - setting: UiSettingMetadata -): UiSettingMetadata => { - const { type } = setting; - return { - ...setting, - category: ['categoryOne', 'categoryTwo'], - description: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu odio velit. Integer et mauris quis ligula elementum commodo. Morbi eu ipsum diam. Nulla auctor orci eget egestas vehicula. Aliquam gravida, dolor eu posuere vulputate, neque enim viverra odio, id viverra ipsum quam et ipsum.', - name: `Some ${type} setting`, - requiresPageReload: false, - }; -}; - -interface OnChangeParams { - value?: KnownTypeToValue | null; - isInvalid?: boolean; - error?: string; -} - -type OnChangeFn = (params: OnChangeParams | null) => void; - -/** - * Hook to build and maintain a {@link FieldDefinition} for a given {@link UiSettingMetadata} object - * for use in Storybook. It provides the {@link FieldDefinition}, a stateful - * {@link UnsavedFieldChange} object, and an {@link OnChangeFn} to update the unsaved change based - * on the action taken within a {@link FieldInput} or {@link FieldRow}. - */ -export const useFieldDefinition = ( - baseSetting: UiSettingMetadata, - params: { isCustom?: boolean; isOverridden?: boolean; isDeprecated?: boolean } = {} -): [FieldDefinition, UnsavedFieldChange, OnChangeFn] => { - const setting = { - ...expandSetting(baseSetting), - deprecation: params.isDeprecated - ? { message: 'This setting is deprecated', docLinksKey: 'storybook' } - : undefined, - }; - - const field = getFieldDefinition({ - id: setting.name?.split(' ').join(':').toLowerCase() || setting.type, - setting, - params, - }); - - const { type, savedValue } = field; - - const [unsavedChange, setUnsavedChange] = useState>({ type }); - - const onChange: OnChangeFn = (change) => { - if (!change) { - return; - } - - const { value, error, isInvalid } = change; - - if (isEqual(value, savedValue)) { - setUnsavedChange({ type }); - } else { - setUnsavedChange({ type, unsavedValue: value, error, isInvalid }); - } - - const formattedSavedValue = type === 'image' ? String(savedValue).slice(0, 25) : savedValue; - const formattedUnsavedValue = type === 'image' ? String(value).slice(0, 25) : value; - - action('onChange')({ - type, - unsavedValue: formattedUnsavedValue, - savedValue: formattedSavedValue, - }); - }; - - return [field, unsavedChange, onChange]; -}; diff --git a/packages/kbn-management/settings/field_definition/tsconfig.json b/packages/kbn-management/settings/field_definition/tsconfig.json index 4b85716365f5a..26d2dc3afb883 100644 --- a/packages/kbn-management/settings/field_definition/tsconfig.json +++ b/packages/kbn-management/settings/field_definition/tsconfig.json @@ -15,5 +15,7 @@ ], "kbn_references": [ "@kbn/management-settings-types", + "@kbn/core-ui-settings-browser", + "@kbn/management-settings-utilities", ] } diff --git a/packages/kbn-management/settings/types/index.ts b/packages/kbn-management/settings/types/index.ts index cc4d1738997a6..08cd1ae1df3bb 100644 --- a/packages/kbn-management/settings/types/index.ts +++ b/packages/kbn-management/settings/types/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +import { SettingType } from './setting_type'; +import { UnsavedFieldChange } from './unsaved_change'; + export type { ArrayFieldDefinition, BooleanFieldDefinition, @@ -33,6 +36,7 @@ export type { UndefinedUiSettingMetadata, UiSettingMetadata, KnownTypeToMetadata, + UiSetting, } from './metadata'; export type { @@ -59,3 +63,17 @@ export type { UndefinedSettingType, Value, } from './setting_type'; + +/** + * A React `ref` that indicates an input can be reset using an + * imperative handle. + */ +export type ResetInputRef = { + reset: () => void; +} | null; + +/** + * A function that is called when the value of a {@link FieldInput} changes. + * @param change The {@link UnsavedFieldChange} passed to the handler. + */ +export type OnChangeFn = (change?: UnsavedFieldChange) => void; diff --git a/packages/kbn-management/settings/types/kibana.jsonc b/packages/kbn-management/settings/types/kibana.jsonc index 9482b2bb0f15f..2a8c86bd31196 100644 --- a/packages/kbn-management/settings/types/kibana.jsonc +++ b/packages/kbn-management/settings/types/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", "id": "@kbn/management-settings-types", - "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" -} + "owner": "@elastic/platform-deployment-management" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/types/metadata.ts b/packages/kbn-management/settings/types/metadata.ts index 8e191310b943d..c0a79549039de 100644 --- a/packages/kbn-management/settings/types/metadata.ts +++ b/packages/kbn-management/settings/types/metadata.ts @@ -12,7 +12,7 @@ import { KnownTypeToValue, SettingType } from './setting_type'; /** * Creating this type based on {@link UiSettingsClientCommon} and exporting for ease. */ -type UiSetting = PublicUiSettingsParams & UserProvidedValues; +export type UiSetting = PublicUiSettingsParams & UserProvidedValues; /** * This is an type-safe abstraction over the {@link UiSetting} type, whose fields diff --git a/packages/kbn-management/settings/utilities/field/get_input_value.ts b/packages/kbn-management/settings/utilities/field/get_input_value.ts new file mode 100644 index 0000000000000..5d8b72420e51e --- /dev/null +++ b/packages/kbn-management/settings/utilities/field/get_input_value.ts @@ -0,0 +1,106 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SettingType, UnsavedFieldChange, FieldDefinition } from '@kbn/management-settings-types'; +import { hasUnsavedChange } from './has_unsaved_change'; + +type F = Pick, 'savedValue' | 'defaultValue'>; +type C = UnsavedFieldChange; + +/** + * Convenience function to compare an `array` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `array` {@link FieldDefinition} to compare. + * @param change The `array` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'array'>, change?: C<'array'>): [string[], boolean]; +/** + * Convenience function to compare an `color` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `color` {@link FieldDefinition} to compare. + * @param change The `color` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'color'>, change?: C<'color'>): [string, boolean]; +/** + * Convenience function to compare an `boolean` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `boolean` {@link FieldDefinition} to compare. + * @param change The `boolean` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'boolean'>, change?: C<'boolean'>): [boolean, boolean]; +/** + * Convenience function to compare an `image` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `image` {@link FieldDefinition} to compare. + * @param change The `image` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'image'>, change?: C<'image'>): [string, boolean]; +/** + * Convenience function to compare an `json` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `json` {@link FieldDefinition} to compare. + * @param change The `json` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'json'>, change?: C<'json'>): [string, boolean]; +/** + * Convenience function to compare an `markdown` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `markdown` {@link FieldDefinition} to compare. + * @param change The `markdown` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'markdown'>, change?: C<'markdown'>): [string, boolean]; +/** + * Convenience function to compare an `number` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `number` {@link FieldDefinition} to compare. + * @param change The `number` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'number'>, change?: C<'number'>): [number, boolean]; +/** + * Convenience function to compare an `select` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `select` {@link FieldDefinition} to compare. + * @param change The `select` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'select'>, change?: C<'select'>): [string, boolean]; +/** + * Convenience function to compare an `string` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `string` {@link FieldDefinition} to compare. + * @param change The `string` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue(field: F<'string'>, change?: C<'string'>): [string, boolean]; +/** + * Convenience function to compare an `undefined` {@link FieldDefinition} and its {@link UnsavedFieldChange}, + * + * @param field The `undefined` {@link FieldDefinition} to compare. + * @param change The `undefined` {@link UnsavedFieldChange } to compare. + */ +export function getFieldInputValue( + field: F<'undefined'>, + change?: C<'undefined'> +): [string | null | undefined, boolean]; +/** + * Convenience function that, given a {@link FieldDefinition} and an {@link UnsavedFieldChange}, + * returns the value to be displayed in the input field, and a boolean indicating whether the + * value is an unsaved value. + * + * @param field The {@link FieldDefinition} to compare. + * @param change The {@link UnsavedFieldChange} to compare. + */ +export function getFieldInputValue(field: F, change?: C) { + const isUnsavedChange = hasUnsavedChange(field, change); + + const value = isUnsavedChange + ? change?.unsavedValue + : field.savedValue !== undefined && field.savedValue !== null + ? field.savedValue + : field.defaultValue; + + return [value, isUnsavedChange]; +} diff --git a/packages/kbn-management/settings/utilities/field/has_unsaved_change.test.ts b/packages/kbn-management/settings/utilities/field/has_unsaved_change.test.ts new file mode 100644 index 0000000000000..f2761ab561c97 --- /dev/null +++ b/packages/kbn-management/settings/utilities/field/has_unsaved_change.test.ts @@ -0,0 +1,53 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { hasUnsavedChange } from './has_unsaved_change'; + +describe('hasUnsavedChange', () => { + it('returns false if the unsaved change is undefined', () => { + expect(hasUnsavedChange({ savedValue: 'foo', defaultValue: 'bar' })).toBe(false); + }); + + it('returns true if the unsaved change value is undefined or null', () => { + expect( + hasUnsavedChange({ savedValue: 'foo', defaultValue: 'bar' }, { unsavedValue: undefined }) + ).toBe(true); + expect( + hasUnsavedChange({ savedValue: 'foo', defaultValue: 'bar' }, { unsavedValue: null }) + ).toBe(true); + }); + + it('returns false if the unsaved change value is equal to the saved value', () => { + expect( + hasUnsavedChange({ savedValue: 'foo', defaultValue: 'bar' }, { unsavedValue: 'foo' }) + ).toBe(false); + }); + + it('returns false if the saved value is undefined, but the unsaved change value is equal to the default value', () => { + expect( + hasUnsavedChange({ savedValue: undefined, defaultValue: 'bar' }, { unsavedValue: 'bar' }) + ).toBe(false); + }); + + it('returns true if the unsaved change value is not equal to the saved value', () => { + expect( + hasUnsavedChange({ savedValue: 'foo', defaultValue: 'bar' }, { unsavedValue: 'baz' }) + ).toBe(true); + }); + + it('returns true if the saved value is undefined, but the unsaved change value is not equal to the default value', () => { + expect( + hasUnsavedChange({ savedValue: undefined, defaultValue: 'bar' }, { unsavedValue: 'baz' }) + ).toBe(true); + }); + + it('returns false if the saved value is undefined, and the unsaved change value is equal to the default value', () => { + expect( + hasUnsavedChange({ savedValue: undefined, defaultValue: 'bar' }, { unsavedValue: 'bar' }) + ).toBe(false); + }); +}); diff --git a/packages/kbn-management/settings/utilities/has_unsaved_change.ts b/packages/kbn-management/settings/utilities/field/has_unsaved_change.ts similarity index 66% rename from packages/kbn-management/settings/utilities/has_unsaved_change.ts rename to packages/kbn-management/settings/utilities/field/has_unsaved_change.ts index 0ac783b439e4a..08fc5d3c34095 100644 --- a/packages/kbn-management/settings/utilities/has_unsaved_change.ts +++ b/packages/kbn-management/settings/utilities/field/has_unsaved_change.ts @@ -22,14 +22,21 @@ import type { * @param unsavedChange The unsaved change to compare. */ export const hasUnsavedChange = ( - field: Pick, 'savedValue'>, + field: Pick, 'savedValue' | 'defaultValue'>, unsavedChange?: Pick, 'unsavedValue'> ) => { + // If there's no unsaved change, return false. if (!unsavedChange) { return false; } const { unsavedValue } = unsavedChange; - const { savedValue } = field; - return unsavedValue !== undefined && !isEqual(unsavedValue, savedValue); + + const { savedValue, defaultValue } = field; + const hasSavedValue = savedValue !== undefined && savedValue !== null; + + // Return a comparison of the unsaved value to: + // the saved value, if the field has a saved value, or + // the default value, if the field does not have a saved value. + return !isEqual(unsavedValue, hasSavedValue ? savedValue : defaultValue); }; diff --git a/packages/kbn-management/settings/utilities/field/index.ts b/packages/kbn-management/settings/utilities/field/index.ts new file mode 100644 index 0000000000000..b82ea8926089c --- /dev/null +++ b/packages/kbn-management/settings/utilities/field/index.ts @@ -0,0 +1,12 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getFieldInputValue } from './get_input_value'; +export { hasUnsavedChange } from './has_unsaved_change'; +export { isFieldDefaultValue } from './is_default_value'; +export { useUpdate, type UseUpdateParameters } from './use_update'; diff --git a/packages/kbn-management/settings/utilities/field/is_default_value.ts b/packages/kbn-management/settings/utilities/field/is_default_value.ts new file mode 100644 index 0000000000000..a0a6dec865e7a --- /dev/null +++ b/packages/kbn-management/settings/utilities/field/is_default_value.ts @@ -0,0 +1,35 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import isEqual from 'lodash/isEqual'; + +import { FieldDefinition, SettingType, UnsavedFieldChange } from '@kbn/management-settings-types'; +import { hasUnsavedChange } from './has_unsaved_change'; + +type F = Pick, 'savedValue' | 'defaultValue'>; +type C = UnsavedFieldChange; + +/** + * Utility function to determine if a given value is equal to the default value of + * a {@link FieldDefinition}. + * + * @param field The field to compare. + * @param change The unsaved change to compare. + */ +export function isFieldDefaultValue(field: F, change?: C): boolean { + const { defaultValue } = field; + const isUnsavedChange = hasUnsavedChange(field, change); + + const value = isUnsavedChange + ? change?.unsavedValue + : field.savedValue !== undefined && field.savedValue !== null + ? field.savedValue + : field.defaultValue; + + return isEqual(value, defaultValue); +} diff --git a/packages/kbn-management/settings/utilities/field/use_update.ts b/packages/kbn-management/settings/utilities/field/use_update.ts new file mode 100644 index 0000000000000..4744d59dd90e7 --- /dev/null +++ b/packages/kbn-management/settings/utilities/field/use_update.ts @@ -0,0 +1,36 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FieldDefinition, SettingType, OnChangeFn } from '@kbn/management-settings-types'; +import { hasUnsavedChange } from './has_unsaved_change'; + +export interface UseUpdateParameters { + /** The {@link OnChangeFn} to invoke. */ + onChange: OnChangeFn; + /** The {@link FieldDefinition} to use to create an update. */ + field: Pick, 'defaultValue' | 'savedValue'>; +} + +/** + * Hook to provide a standard {@link OnChangeFn} that will send an update to the + * field. + * + * @param params The {@link UseUpdateParameters} to use. + * @returns An {@link OnChangeFn} that will send an update to the field. + */ +export const useUpdate = (params: UseUpdateParameters): OnChangeFn => { + const { onChange, field } = params; + + return (update) => { + if (hasUnsavedChange(field, update)) { + onChange(update); + } else { + onChange(); + } + }; +}; diff --git a/packages/kbn-management/settings/utilities/get_input_value.ts b/packages/kbn-management/settings/utilities/get_input_value.ts deleted file mode 100644 index 17ae6833fdb81..0000000000000 --- a/packages/kbn-management/settings/utilities/get_input_value.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { SettingType, UnsavedFieldChange, FieldDefinition } from '@kbn/management-settings-types'; -import { hasUnsavedChange } from './has_unsaved_change'; - -type F = Pick, 'savedValue' | 'defaultValue'>; -type C = UnsavedFieldChange; - -/** - * Convenience function that, given a {@link FieldDefinition} and an {@link UnsavedFieldChange}, - * returns the value to be displayed in the input field, and a boolean indicating whether the - * value is an unsaved value. - * - * @param field The field to compare. - * @param change The unsaved change to compare. - */ -export function getInputValue(field: F<'array'>, change: C<'array'>): [string[], boolean]; -export function getInputValue(field: F<'color'>, change: C<'color'>): [string, boolean]; -export function getInputValue(field: F<'boolean'>, change: C<'boolean'>): [boolean, boolean]; -export function getInputValue(field: F<'image'>, change: C<'image'>): [string, boolean]; -export function getInputValue(field: F<'json'>, change: C<'json'>): [string, boolean]; -export function getInputValue(field: F<'markdown'>, change: C<'markdown'>): [string, boolean]; -export function getInputValue(field: F<'number'>, change: C<'number'>): [number, boolean]; -export function getInputValue(field: F<'select'>, change: C<'select'>): [string, boolean]; -export function getInputValue(field: F<'string'>, change: C<'string'>): [string, boolean]; -export function getInputValue( - field: F<'undefined'>, - change: C<'undefined'> -): [string | null | undefined, boolean]; -export function getInputValue(field: F, change: C) { - const isUnsavedValue = hasUnsavedChange(field, change); - - const value = isUnsavedValue - ? change.unsavedValue - : field.savedValue !== undefined && field.savedValue !== null - ? field.savedValue - : field.defaultValue; - - return [value, isUnsavedValue]; -} diff --git a/packages/kbn-management/settings/utilities/index.ts b/packages/kbn-management/settings/utilities/index.ts index 1c35af180866d..4e4523f66eb59 100644 --- a/packages/kbn-management/settings/utilities/index.ts +++ b/packages/kbn-management/settings/utilities/index.ts @@ -6,6 +6,11 @@ * Side Public License, v 1. */ -export { hasUnsavedChange } from './has_unsaved_change'; -export { isUnsavedValue } from './is_unsaved_value'; -export { getInputValue } from './get_input_value'; +export { isSettingDefaultValue, normalizeSettings } from './setting'; +export { + getFieldInputValue, + hasUnsavedChange, + isFieldDefaultValue, + useUpdate, + type UseUpdateParameters, +} from './field'; diff --git a/packages/kbn-management/settings/utilities/is_unsaved_value.ts b/packages/kbn-management/settings/utilities/is_unsaved_value.ts deleted file mode 100644 index 863d6c8b59ba0..0000000000000 --- a/packages/kbn-management/settings/utilities/is_unsaved_value.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import isEqual from 'lodash/isEqual'; - -import { FieldDefinition, KnownTypeToValue, SettingType } from '@kbn/management-settings-types'; - -/** - * Convenience function to compare a given {@link FieldDefinition} to an {@link UnsavedFieldChange} - * to determine if the value in the unsaved change is a different value from what is saved. - * - * @param field The field to compare. - * @param unsavedValue The unsaved value to compare. - */ -export const isUnsavedValue = ( - field: FieldDefinition, - unsavedValue?: KnownTypeToValue | null -) => { - const { savedValue } = field; - - return unsavedValue !== undefined && !isEqual(unsavedValue, savedValue); -}; diff --git a/packages/kbn-management/settings/utilities/kibana.jsonc b/packages/kbn-management/settings/utilities/kibana.jsonc index 391d209e9f192..ebafaeba6d884 100644 --- a/packages/kbn-management/settings/utilities/kibana.jsonc +++ b/packages/kbn-management/settings/utilities/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", "id": "@kbn/management-settings-utilities", - "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" -} + "owner": "@elastic/platform-deployment-management" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/utilities/setting/index.ts b/packages/kbn-management/settings/utilities/setting/index.ts new file mode 100644 index 0000000000000..c2dd8f654d1b8 --- /dev/null +++ b/packages/kbn-management/settings/utilities/setting/index.ts @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { isSettingDefaultValue } from './is_default_value'; +export { normalizeSettings } from './normalize_settings'; diff --git a/packages/kbn-management/settings/utilities/setting/is_default_value.ts b/packages/kbn-management/settings/utilities/setting/is_default_value.ts new file mode 100644 index 0000000000000..b59467b7410ac --- /dev/null +++ b/packages/kbn-management/settings/utilities/setting/is_default_value.ts @@ -0,0 +1,30 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SettingType, UiSettingMetadata, Value } from '@kbn/management-settings-types'; +import isEqual from 'lodash/isEqual'; + +/** + * Utility function to compare a value to the default value of a {@link UiSettingMetadata}. + * @param setting The source {@link UiSettingMetadata} object. + * @param userValue The value to compare to the setting's default value. Default is the + * {@link UiSettingMetadata}'s user value. + * @returns True if the provided value is equal to the setting's default value, false otherwise. + */ +export const isSettingDefaultValue = ( + setting: UiSettingMetadata, + userValue: Value = setting.userValue +) => { + const { value } = setting; + + if (userValue === undefined || userValue === null) { + return true; + } + + return isEqual(value, userValue); +}; diff --git a/packages/kbn-management/settings/utilities/setting/normalize_settings.test.ts b/packages/kbn-management/settings/utilities/setting/normalize_settings.test.ts new file mode 100644 index 0000000000000..48ed648e58c2f --- /dev/null +++ b/packages/kbn-management/settings/utilities/setting/normalize_settings.test.ts @@ -0,0 +1,75 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { normalizeSettings } from './normalize_settings'; + +describe('normalizeSettings', () => { + describe('adds a missing type if there is a value', () => { + it('a string value', () => { + const setting = { name: 'foo', value: 'bar' }; + const settings = { foo: setting }; + + expect(normalizeSettings(settings)).toEqual({ + foo: { type: 'string', ...setting }, + }); + }); + it('a boolean value', () => { + const setting = { name: 'foo', value: true }; + const settings = { foo: setting }; + + expect(normalizeSettings(settings)).toEqual({ + foo: { type: 'boolean', ...setting }, + }); + }); + it('an array value', () => { + const setting = { name: 'foo', value: ['foo', 'bar'] }; + const settings = { foo: setting }; + + expect(normalizeSettings(settings)).toEqual({ + foo: { type: 'array', ...setting }, + }); + }); + // + // can't test a bigint value unless Jest is set to use only one + // webworker. see: https://github.com/jestjs/jest/issues/11617 + // + // it('a bigint value', () => { + // const setting = { name: 'foo', value: BigInt(9007199254740991) }; + // const settings = { foo: setting }; + + // expect(normalizeSettings(settings)).toEqual({ + // foo: { type: 'number', ...setting }, + // }); + // }); + // + it('a numeric value', () => { + const setting = { name: 'foo', value: 10 }; + const settings = { foo: setting }; + + expect(normalizeSettings(settings)).toEqual({ + foo: { type: 'number', ...setting }, + }); + }); + }); + + it('throws if the value is an object', () => { + const setting = { name: 'foo', value: { bar: 'baz' } }; + const settings = { foo: setting }; + + expect(() => normalizeSettings(settings)).toThrowError( + `incompatible SettingType: 'foo' type object | {"name":"foo","value":{"bar":"baz"}}` + ); + }); + + it('does nothing if the type and value are already set', () => { + const setting = { name: 'foo', value: 'bar', type: 'string' as 'string' }; + const settings = { foo: setting }; + + expect(normalizeSettings(settings)).toEqual(settings); + }); +}); diff --git a/packages/kbn-management/settings/utilities/setting/normalize_settings.ts b/packages/kbn-management/settings/utilities/setting/normalize_settings.ts new file mode 100644 index 0000000000000..fa247151c7751 --- /dev/null +++ b/packages/kbn-management/settings/utilities/setting/normalize_settings.ts @@ -0,0 +1,118 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SettingType, UiSetting, UiSettingMetadata, Value } from '@kbn/management-settings-types'; + +type RawSettings = Record>; + +/** + * UiSettings have an extremely permissive set of types, which makes it difficult to code + * against them. Sometimes the `type` field-- the property that tells us what input to render + * to change the setting-- is missing. This function attempts to derive that `type` property + * from the `value` or `userValue` fields of the setting. + * + * @param setting The setting from which to derive the type. + * @returns The derived {@link SettingType}. + */ +const deriveType = (setting: UiSetting): SettingType => { + const { type, value: defaultValue, userValue: savedValue } = setting; + + if (type) { + return type; + } + + if (Array.isArray(defaultValue) || Array.isArray(savedValue)) { + return 'array'; + } + + const typeofVal = defaultValue != null ? typeof defaultValue : typeof savedValue; + + if (typeofVal === 'bigint') { + return 'number'; + } + + if (typeofVal === 'boolean') { + return 'boolean'; + } + + if (typeofVal === 'symbol' || typeofVal === 'object' || typeofVal === 'function') { + throw new Error( + `incompatible SettingType: '${setting.name}' type ${typeofVal} | ${JSON.stringify(setting)}` + ); + } + + return typeofVal; +}; + +/** + * UiSettings have an extremely permissive set of types, which makes it difficult to code + * against them. The `value` property is typed as `unknown`, but the setting has a `type` + * property that tells us what type the value should be. This function attempts to cast + * the value from a given type. + * + * @param type The {@link SettingType} to which to cast the value. + * @param value The value to cast. + */ +const deriveValue = (type: SettingType, value: unknown): Value => { + if (value === null) { + return null; + } + + switch (type) { + case 'color': + case 'image': + case 'json': + case 'markdown': + case 'string': + return value as string; + case 'number': + return value ? Number(value) : undefined; + case 'boolean': + return Boolean(value); + case 'array': + return Array.isArray(value) ? value : [value]; + default: + return value as string; + } +}; + +/** + * UiSettings have an extremely permissive set of types, which makes it difficult to code + * against them. The `type` and `value` properties are inherently related, and important, + * but in some cases one or both are missing. This function attempts to normalize the + * settings to a strongly-typed format, {@link UiSettingMetadata} based on the information + * in the setting at runtime. + * + * @param rawSettings The raw settings retrieved from the {@link IUiSettingsClient}, which + * may be missing the `type` or `value` properties. + * @returns A mapped collection of normalized {@link UiSetting} objects. + */ +export const normalizeSettings = ( + rawSettings: RawSettings +): Record> => { + const normalizedSettings: Record> = {}; + + const entries = Object.entries(rawSettings); + + entries.forEach(([id, rawSetting]) => { + const type = deriveType(rawSetting); + const value = deriveValue(type, rawSetting.value); + + const setting = { + ...rawSetting, + type, + value, + }; + + if (setting) { + normalizedSettings[id] = setting; + } + }); + + return normalizedSettings; +}; diff --git a/packages/kbn-management/settings/field_definition/storybook/index.ts b/packages/kbn-management/settings/utilities/storybook/index.ts similarity index 88% rename from packages/kbn-management/settings/field_definition/storybook/index.ts rename to packages/kbn-management/settings/utilities/storybook/index.ts index b372e1db1cf1b..188d7d0d49725 100644 --- a/packages/kbn-management/settings/field_definition/storybook/index.ts +++ b/packages/kbn-management/settings/utilities/storybook/index.ts @@ -7,4 +7,3 @@ */ export { getDefaultValue, getUserValue, IMAGE } from './values'; -export { useFieldDefinition } from './field_definition'; diff --git a/packages/kbn-management/settings/field_definition/storybook/values.ts b/packages/kbn-management/settings/utilities/storybook/values.ts similarity index 100% rename from packages/kbn-management/settings/field_definition/storybook/values.ts rename to packages/kbn-management/settings/utilities/storybook/values.ts