[management] Adjustments to Settings components and utilities (#166413)

> **Caveat**: 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 PR is a prerequisite to
https://github.com/elastic/kibana/pull/166319 and to completing the
Advanced Settings page in Serverless.

> **Note**: Github appears to be a bit confused in the diff comparison,
due to the addition to `React.forwardRef`... apologies for the noise.

## Summary

While working on https://github.com/elastic/kibana/pull/166319 I found a
number of bugs when working against actual UI settings stored in Kibana.
This PR addresses those issues without waiting for the Settings page to
be complete:

- Refactors `input` components to have cleaner APIs, including
`unsavedChange` and `field` "all the way down".
  - This cleans up confusing logic, and sets us up for Redux actions.
- Creates a `normalizeSettings` function.
- Settings returned from the `UiSettingsService` in an actual deployment
of Kibana are drastically unpredictable. In some cases, `type` is
missing, but `value` is there... or `value` is missing entirely, but a
`userValue` is there.
- This function "normalizes" the data, deriving missing parts where
possible.
- Changes the `onChangeFn` to accept `undefined` to indicate an unsaved
change has been reverted, rather than relying on the _value_ in the
unsaved change.
  - This fixes a number of issues around resets and reverts.
- Alters the `unsavedChange` prop to be undefined, (to indicate the lack
of an unsaved change), rather than an undefined value.
- Fixes an issue where the `ImageFieldInput` wasn't removing a file that
had been set when resetting to default;
- Adds an imperative ref to `FieldInput` components to reset a field's
input beyond resetting the value, (if necessary).
- Fixes the Storybook `common` setups to allow for changes to the
`onChange` types.
- Fixed a bug where the `FieldRow` was indexing an unsaved change by
`name`, rather than by `id`.
- Fixed an issue where the reset link wasn't always clearing a change to
the default value.
- Fixes an issue with the aria label not being derived properly using
the `query`.
- Splits the utility functions into their respective namespaces:
`settings` and `fields`.
- Adds a few more tests to the utility functions to catch logic errors.
This commit is contained in:
Clint Andrew Hall 2023-09-19 13:27:55 -04:00 committed by GitHub
parent dd4708414a
commit 20be3d0b5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 1807 additions and 1009 deletions

10
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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

View file

@ -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<T extends SettingType> = Pick<InputProps<T>, 'value' | 'isDisabled'>;
export type StoryProps<T extends SettingType> = Pick<InputProps<T>, 'isSavingEnabled'> &
Pick<UiSettingMetadata<T>, '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<typeof type>) => {
const Story = ({ userValue, value, isSavingEnabled }: StoryProps<typeof type>) => {
const [unsavedChange, setUnsavedChange] = useState<
UnsavedFieldChange<typeof type> | undefined
>();
const setting: UiSettingMetadata<typeof type> = {
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<typeof type> = (newChange) => {
onChangeFn(newChange);
setUnsavedChange(newChange);
action('onChange')({
type,
unsavedValue: newChange?.unsavedValue,
savedValue: field.savedValue,
});
};
return (
<FieldInput
{...{ field, isInvalid: unsavedChange.isInvalid, unsavedChange, onChange, isDisabled }}
/>
);
return <FieldInput {...{ field, unsavedChange, onChange, isSavingEnabled }} />;
};
Story.argTypes = {
...params.argTypes,
};
Story.args = {
isSavingEnabled: true,
value: getDefaultValue(type),
userValue: getUserValue(type),
...params.argTypes,
...storyArgs,
};
return Story;

View file

@ -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',
};

View file

@ -56,6 +56,7 @@ describe('FieldInput', () => {
options,
} as FieldDefinition<typeof type>,
onChange: jest.fn(),
isSavingEnabled: true,
};
return props;
@ -131,12 +132,12 @@ describe('FieldInput', () => {
const { getByTestId } = render(wrap(<FieldInput {...props} />));
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(<FieldInput {...props} isDisabled />));
const { getByTestId } = render(wrap(<FieldInput {...props} isSavingEnabled={false} />));
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<SettingType>;
expect(() => render(wrap(<FieldInput {...props} />))).toThrowError(
'Unknown or incompatible field type: foobar'

View file

@ -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<T extends SettingType> {
/** The {@link FieldDefinition} for the component. */
field: FieldDefinition<T>;
field: Pick<FieldDefinition<T>, 'type' | 'id' | 'name' | 'ariaAttributes'>;
/** An {@link UnsavedFieldChange} for the component, if any. */
unsavedChange?: UnsavedFieldChange<T>;
/** The `onChange` handler for the input. */
onChange: OnChangeFn<T>;
/** 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 = <T extends SettingType>(props: FieldInputProps<T>) => {
const {
field,
unsavedChange,
isDisabled = false,
isInvalid = false,
onChange: onChangeProp,
} = props;
const { id, name, ariaAttributes } = field;
export const FieldInput = React.forwardRef<ResetInputRef, FieldInputProps<SettingType>>(
(props, ref) => {
const { field, unsavedChange, onChange, isSavingEnabled } = props;
const inputProps = {
...ariaAttributes,
id,
isDisabled,
isInvalid,
name,
};
// Create a ref for those input fields that require an imperative handle.
const inputRef = useRef<ResetInputRef>(null);
// 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);
// 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 <ArrayInput {...{ field, unsavedChange, ...inputProps }} />;
}
const [value] = getInputValue(field, unsavedChange);
if (isBooleanFieldDefinition(field)) {
if (!isBooleanFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
// 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'>;
return <ArrayInput {...{ ...inputProps, onChange, value }} />;
}
if (isBooleanFieldDefinition(field)) {
if (!isBooleanFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return <BooleanInput {...{ field, unsavedChange, ...inputProps }} />;
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'boolean'>;
if (isColorFieldDefinition(field)) {
if (!isColorFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <BooleanInput {...{ ...inputProps, onChange, value }} />;
}
if (isColorFieldDefinition(field)) {
if (!isColorFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return <ColorPickerInput {...{ field, unsavedChange, ...inputProps }} />;
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'color'>;
if (isImageFieldDefinition(field)) {
if (!isImageFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <ColorPickerInput {...{ ...inputProps, onChange, value }} />;
}
if (isImageFieldDefinition(field)) {
if (!isImageFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return <ImageInput {...{ field, unsavedChange, ...inputProps }} ref={inputRef} />;
}
const [value, unsaved] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'image'>;
if (isJsonFieldDefinition(field)) {
if (!isJsonFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<ImageInput
{...{ ...inputProps, onChange, value }}
isDefaultValue={field.isDefaultValue}
hasChanged={unsaved}
/>
);
}
if (isJsonFieldDefinition(field)) {
if (!isJsonFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return (
<CodeEditorInput
{...{ field, unsavedChange, ...inputProps }}
type="json"
defaultValue={field.savedValue || ''}
/>
);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'json'>;
if (isMarkdownFieldDefinition(field)) {
if (!isMarkdownFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<CodeEditorInput
{...{ ...inputProps, onChange, value }}
type="json"
defaultValue={field.savedValue || ''}
/>
);
}
if (isMarkdownFieldDefinition(field)) {
if (!isMarkdownFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return (
<CodeEditorInput
{...{ field, unsavedChange, ...inputProps }}
type="markdown"
defaultValue={field.savedValue || ''}
/>
);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'markdown'>;
if (isNumberFieldDefinition(field)) {
if (!isNumberFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<CodeEditorInput
{...{ ...inputProps, onChange, value }}
type="markdown"
defaultValue={field.savedValue || ''}
/>
);
}
if (isNumberFieldDefinition(field)) {
if (!isNumberFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return <NumberInput {...{ field, unsavedChange, ...inputProps }} />;
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'number'>;
if (isSelectFieldDefinition(field)) {
if (!isSelectFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <NumberInput {...{ ...inputProps, onChange, value }} />;
}
const {
options: { values: optionValues, labels: optionLabels },
} = field;
if (isSelectFieldDefinition(field)) {
if (!isSelectFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return (
<SelectInput {...{ field, unsavedChange, optionLabels, optionValues, ...inputProps }} />
);
}
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 <SelectInput {...{ ...inputProps, onChange, optionLabels, optionValues, value }} />;
}
if (isStringFieldDefinition(field)) {
if (!isStringFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return <TextInput {...{ field, unsavedChange, ...inputProps }} />;
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'string'>;
if (isUndefinedFieldDefinition(field)) {
if (!isUndefinedFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <TextInput {...{ ...inputProps, onChange, value }} />;
}
if (isUndefinedFieldDefinition(field)) {
if (!isUndefinedFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
return (
<TextInput
field={field as unknown as FieldDefinition<'string'>}
unsavedChange={unsavedChange as unknown as UnsavedFieldChange<'string'>}
{...inputProps}
/>
);
}
const [value] = getInputValue(field, unsavedChange);
return <TextInput {...{ ...(inputProps as unknown as TextInputProps), value }} />;
throw new Error(`Unknown or incompatible field type: ${field.type}`);
}
throw new Error(`Unknown or incompatible field type: ${field.type}`);
};
);

View file

@ -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';

View file

@ -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(
<ArrayInput
{...defaultProps}
field={{ ...defaultProps.field, savedValue: ['foo', 'bar', 'baz'] }}
/>
)
);
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toHaveValue('foo, bar, baz');
});
it('formats array when blurred', () => {
render(wrap(<ArrayInput {...defaultProps} />));
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(<ArrayInput {...defaultProps} isDisabled />));
const { getByTestId } = render(wrap(<ArrayInput {...defaultProps} isSavingEnabled={false} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});

View file

@ -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 (
<EuiFieldText
fullWidth
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
disabled={!isSavingEnabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
onChange={(event) => setValue(event.target.value)}
{...{ name, onBlur, value }}
{...{ name, onBlur, onChange, value }}
/>
);
};

View file

@ -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();
});
it('renders true', () => {
render(wrap(<BooleanInput value={true} {...defaultProps} />));
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked();
onChange.mockClear();
});
it('renders false', () => {
render(wrap(<BooleanInput value={false} {...defaultProps} />));
render(wrap(<BooleanInput {...defaultProps} />));
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).not.toBeChecked();
});
it('renders true', () => {
render(
wrap(<BooleanInput {...defaultProps} field={{ ...defaultProps.field, defaultValue: true }} />)
);
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked();
});
it('renders unsaved value if present', () => {
render(
wrap(
<BooleanInput {...defaultProps} unsavedChange={{ type: 'boolean', unsavedValue: true }} />
)
);
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked();
});
it('calls onChange when toggled', () => {
render(wrap(<BooleanInput value={true} {...defaultProps} />));
render(wrap(<BooleanInput {...defaultProps} />));
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);

View file

@ -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 (
<EuiSwitch
@ -47,7 +54,8 @@ export const BooleanInput = ({
aria-describedby={ariaDescribedBy}
checked={!!value}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
{...{ disabled, name, onChange }}
disabled={!isSavingEnabled}
{...{ name, onChange }}
/>
);
};

View file

@ -9,10 +9,12 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { SettingType } from '@kbn/management-settings-types';
import { CodeEditor } from '../code_editor';
import type { InputProps, OnChangeFn } from '../types';
import { SettingType } from '@kbn/management-settings-types';
import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities';
import { CodeEditor, CodeEditorProps } from '../code_editor';
import type { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
type Type = Extract<SettingType, 'json' | 'markdown'>;
@ -23,11 +25,6 @@ type Type = Extract<SettingType, 'json' | 'markdown'>;
export interface CodeEditorInputProps extends InputProps<Type> {
/** The default value of the {@link CodeEditor} component. */
defaultValue?: string;
/**
* The `onChange` event handler, expanded to include both `markdown`
* and `json`
*/
onChange: OnChangeFn<Type>;
/**
* The {@link UiSettingType}, expanded to include both `markdown`
* and `json`
@ -41,23 +38,23 @@ export interface CodeEditorInputProps extends InputProps<Type> {
* TODO: clintandrewhall - `kibana_react` `CodeEditor` does not support `disabled`.
*/
export const CodeEditorInput = ({
ariaDescribedBy,
ariaLabel,
defaultValue,
id,
isDisabled = false,
onChange: onChangeProp,
field,
unsavedChange,
type,
value: valueProp = '',
isSavingEnabled,
defaultValue,
onChange: onChangeProp,
}: 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 (
<div>
@ -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 }}
/>

View file

@ -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(<ColorPickerInput {...defaultProps} />));
expect(container).toBeInTheDocument();
beforeEach(() => {
onChange.mockClear();
});
it('renders the value prop', () => {
const { getByRole } = render(wrap(<ColorPickerInput {...defaultProps} />));
it('renders without errors', () => {
const { container, getByRole } = render(wrap(<ColorPickerInput {...defaultProps} />));
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(<ColorPickerInput {...defaultProps} />));
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(<ColorPickerInput {...defaultProps} isDisabled />));
const { getByRole } = render(
wrap(<ColorPickerInput {...defaultProps} isSavingEnabled={false} />)
);
const input = getByRole('textbox');
expect(input).toBeDisabled();
});

View file

@ -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 (
<EuiColorPicker
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
disabled={!isSavingEnabled}
format="hex"
isInvalid={isInvalid}
isInvalid={unsavedChange?.isInvalid}
{...{ name, color, onChange }}
/>
);

View file

@ -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(<ImageInput {...defaultProps} />));
expect(container).toBeInTheDocument();
@ -48,7 +59,7 @@ describe('ImageInput', () => {
});
it('disables the input when isDisabled prop is true', () => {
const { getByTestId } = render(wrap(<ImageInput {...defaultProps} isDisabled />));
const { getByTestId } = render(wrap(<ImageInput {...defaultProps} isSavingEnabled={false} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});

View file

@ -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<string | ArrayBuffer> => {
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<EuiFilePicker, ImageInputProps>(
(
{
ariaDescribedBy,
ariaLabel,
id,
isDisabled,
isDefaultValue,
onChange: onChangeProp,
name,
value,
hasChanged,
},
ref
) => {
export const ImageInput = React.forwardRef<ResetInputRef, ImageInputProps>(
({ field, unsavedChange, isSavingEnabled, onChange: onChangeProp }, ref) => {
const inputRef = useRef<EuiFilePicker>(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<EuiFilePicker, ImageInputProps>(
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<EuiFilePicker, ImageInputProps>(
// 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 <EuiImage allowFullScreen url={value} alt={name} {...a11yProps} />;
} else {
return (
<EuiFilePicker
accept=".jpg,.jpeg,.png"
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
disabled={!isSavingEnabled}
ref={inputRef}
fullWidth
{...{ onChange, ref, ...a11yProps }}
{...{ onChange, ...a11yProps }}
/>
);
}

View file

@ -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(<CodeEditorInput {...defaultProps} />);
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(<CodeEditorInput {...defaultProps} />);
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(<CodeEditorInput {...props} />);
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,
});

View file

@ -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) => (
<input
data-test-subj="management-settings-editField-some:json:field"
data-test-subj="management-settings-editField-some:markdown:field"
type="text"
value={String(value)}
onChange={(e) => {
@ -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(<CodeEditorInput {...defaultProps} />);
expect(container).toBeInTheDocument();
@ -57,6 +69,9 @@ describe('JsonEditorInput', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
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',
});
});
});

View file

@ -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,
};
it('renders without errors', () => {
const { container } = render(wrap(<NumberInput {...defaultProps} />));
expect(container).toBeInTheDocument();
beforeEach(() => {
onChange.mockClear();
});
it('renders the value prop', () => {
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} />));
it('renders without errors', () => {
const { container, getByTestId } = render(wrap(<NumberInput {...defaultProps} />));
expect(container).toBeInTheDocument();
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(defaultProps.value);
expect(input).toHaveValue(defaultProps.field.defaultValue);
});
it('renders the saved value if present', () => {
const { getByTestId } = render(
wrap(<NumberInput {...defaultProps} field={{ ...defaultProps.field, savedValue: 9876 }} />)
);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(9876);
});
it('renders the unsaved value if present', () => {
const { getByTestId } = render(
wrap(
<NumberInput
{...defaultProps}
field={{ ...defaultProps.field, savedValue: 9876 }}
unsavedChange={{ type: 'number', unsavedValue: 4321 }}
/>
)
);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(4321);
});
it('calls the onChange prop when the value changes', () => {
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} />));
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(<NumberInput {...defaultProps} isDisabled />));
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} isSavingEnabled={false} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});
it('recovers if value is null', () => {
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} value={null} />));
const { getByTestId } = render(
wrap(<NumberInput {...defaultProps} field={{ ...defaultProps.field, defaultValue: null }} />)
);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
waitFor(() => expect(input).toHaveValue(undefined));
});

View file

@ -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<HTMLInputElement>) =>
onChangeProp({ value: Number(event.target.value) });
const onChange: EuiFieldNumberProps['onChange'] = (event) => {
const inputValue = Number(event.target.value);
onUpdate({ type: field.type, unsavedValue: inputValue });
};
// 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 onUpdate = useUpdate({ onChange: onChangeProp, field });
const { id, name, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;
const [rawValue] = getFieldInputValue(field, unsavedChange);
const value = rawValue === null ? undefined : rawValue;
return (
<EuiFieldNumber
@ -45,7 +47,8 @@ export const NumberInput = ({
aria-label={ariaLabel}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
fullWidth
{...{ disabled, name, value, onChange }}
disabled={!isSavingEnabled}
{...{ name, value, onChange }}
/>
);
};

View file

@ -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(<SelectInput {...defaultProps} />));
expect(container).toBeInTheDocument();
beforeEach(() => {
onChange.mockClear();
});
it('renders the value prop', () => {
const { getByTestId } = render(wrap(<SelectInput {...defaultProps} />));
it('renders without errors', () => {
const { container, getByTestId } = render(wrap(<SelectInput {...defaultProps} />));
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(<SelectInput {...defaultProps} />));
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(<SelectInput {...defaultProps} isDisabled />));
const { getByTestId } = render(wrap(<SelectInput {...defaultProps} isSavingEnabled={false} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});

View file

@ -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<HTMLSelectElement>) => {
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 (
<EuiSelect
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
disabled={!isSavingEnabled}
fullWidth
{...{ onChange, options, value }}
/>

View file

@ -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(<TextInput {...defaultProps} />);
expect(container).toBeInTheDocument();
@ -39,11 +51,14 @@ describe('TextInput', () => {
const { getByTestId } = render(<TextInput {...defaultProps} />);
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(<TextInput {...defaultProps} isDisabled />);
const { getByTestId } = render(<TextInput {...defaultProps} isSavingEnabled={false} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});

View file

@ -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<HTMLInputElement>) =>
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 (
<EuiFieldText
fullWidth
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
disabled={!isSavingEnabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
{...{ name, onChange, value }}

View file

@ -1,5 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/management-settings-components-field-input",
"owner": "@elastic/platform-deployment-management @elastic/appex-sharedux"
}
"owner": "@elastic/platform-deployment-management"
}

View file

@ -6,9 +6,13 @@
* Side Public License, v 1.
*/
import { SettingType } from '@kbn/management-settings-types';
import {
FieldDefinition,
OnChangeFn,
SettingType,
UnsavedFieldChange,
} from '@kbn/management-settings-types';
import { ToastsStart } from '@kbn/core-notifications-browser';
import { KnownTypeToValue } from '@kbn/management-settings-types';
/**
* Contextual services used by a {@link FieldInput} component.
@ -33,32 +37,13 @@ export interface FieldInputKibanaDependencies {
/**
* Props passed to a {@link FieldInput} component.
*/
export interface InputProps<T extends SettingType, V = KnownTypeToValue<T> | null> {
id: string;
ariaDescribedBy?: string;
ariaLabel: string;
isDisabled?: boolean;
isInvalid?: boolean;
value?: V;
name: string;
export interface InputProps<T extends SettingType> {
field: Pick<
FieldDefinition<T>,
'ariaAttributes' | 'defaultValue' | 'id' | 'name' | 'savedValue' | 'type' | 'isOverridden'
>;
unsavedChange?: UnsavedFieldChange<T>;
isSavingEnabled: boolean;
/** The `onChange` handler. */
onChange: OnChangeFn<T>;
}
/**
* Parameters for the {@link OnChangeFn} handler.
*/
export interface OnChangeParams<T extends SettingType> {
/** The value provided to the handler. */
value?: KnownTypeToValue<T> | 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<T extends SettingType> = (params: OnChangeParams<T>) => void;

View file

@ -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<UiSettingMetadata<SettingType>>
settingFields?: Partial<UiSettingMetadata<SettingType>>
) => {
const Story = ({
isCustom,
@ -118,33 +115,61 @@ export const getFieldRowStory = (
userValue,
value,
}: StoryProps<typeof type>) => {
const [unsavedChange, setUnsavedChange] = useState<
UnsavedFieldChange<typeof type> | undefined
>();
const setting: UiSettingMetadata<typeof type> = {
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<typeof type> = (_key, change) => {
const { error, isInvalid, unsavedValue } = change;
onChangeFn({ error: error === null ? undefined : error, isInvalid, value: unsavedValue });
const onChange: RowOnChangeFn<typeof type> = (_id, newChange) => {
setUnsavedChange(newChange);
action('onChange')({
type,
unsavedValue: newChange?.unsavedValue,
savedValue: field.savedValue,
});
};
return <FieldRow {...{ field, unsavedChange, isSavingEnabled, onChange }} />;
};
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;
};

View file

@ -9,7 +9,7 @@
import { getFieldRowStory, getStory } from './common';
const argTypes = {
value: {
userValue: {
name: 'Current saved value',
control: {
type: 'select',

View file

@ -46,6 +46,28 @@ describe('FieldDefaultValue', () => {
expect(container).toBeEmptyDOMElement();
});
it('renders nothing if an unsaved change matches the default value', () => {
const { container } = render(
wrap(
<FieldDefaultValue
field={{
id: 'test',
type: 'string',
isDefaultValue: false,
defaultValueDisplay: 'null',
defaultValue: 'test',
}}
unsavedChange={{
type: 'string',
unsavedValue: 'test',
}}
/>
)
);
expect(container).toBeEmptyDOMElement();
});
it('does not render a code block for string fields', () => {
const { queryByTestId, getByText } = render(
wrap(

View file

@ -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<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<FieldDefinition<T>, 'id' | 'type' | 'isDefaultValue' | 'defaultValueDisplay'>;
field: Pick<
FieldDefinition<T>,
'id' | 'type' | 'isDefaultValue' | 'defaultValueDisplay' | 'defaultValue'
>;
unsavedChange?: UnsavedFieldChange<T>;
}
/**
* Component for displaying the default value of a {@link FieldDefinition}
* in the {@link FieldRow}.
*/
export const FieldDefaultValue = <T extends SettingType>({ field }: FieldDefaultValueProps<T>) => {
export const FieldDefaultValue = <T extends SettingType>({
field,
unsavedChange,
}: FieldDefaultValueProps<T>) => {
if (field.isDefaultValue) {
return null;
}
if (
unsavedChange &&
(unsavedChange.unsavedValue === field.defaultValue || unsavedChange.unsavedValue === undefined)
) {
return null;
}
const { defaultValueDisplay: display, id } = field;
let value = <EuiCode>{display}</EuiCode>;

View file

@ -14,6 +14,7 @@ import { wrap } from '../mocks';
describe('FieldDeprecation', () => {
const defaultProps = {
field: {
id: 'test:field',
name: 'test',
type: 'string',
deprecation: undefined,

View file

@ -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';

View file

@ -36,6 +36,19 @@ describe('FieldDescription', () => {
expect(getByText(description)).toBeInTheDocument();
});
it('renders a React Element', () => {
const value = 'This is a description.';
const element = <div>{value}</div>;
const { getByText } = render(
wrap(
<FieldDescription
{...{ ...defaultProps, field: { ...defaultProps.field, description: element } }}
/>
)
);
expect(getByText(value)).toBeInTheDocument();
});
it('renders no description without one', () => {
const { queryByText } = render(wrap(<FieldDescription {...defaultProps} />));
expect(queryByText(description)).toBeNull();

View file

@ -75,7 +75,7 @@ export const FieldDescription = <T extends SettingType>({
<div css={cssDescription}>
<FieldDeprecation {...{ field }} />
{content}
<FieldDefaultValue {...{ field }} />
<FieldDefaultValue {...{ field, unsavedChange }} />
</div>
);
};

View file

@ -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<Settings, 'markdown' | 'json'> = {
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<Settings, 'markdown' | 'json'> = {
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<Settings, 'markdown' | 'json'> = {
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<Settings, 'markdown' | 'json'> = {
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<Settings, 'markdown' | 'json'> = {
// 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<Settings, 'markdown' | 'json'> = {
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<Settings, 'markdown' | 'json'> = {
banana: 'Banana',
},
type: 'select',
userValue: undefined,
userValue: null,
value: defaultValues.select,
...defaults,
},
@ -162,7 +162,7 @@ const settings: Omit<Settings, 'markdown' | 'json'> = {
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<Settings, 'markdown' | 'json'> = {
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(
<FieldRow
field={field}
unsavedChange={{ type, unsavedValue: userValues[type] }}
onChange={handleChange}
isSavingEnabled={true}
/>
)
);
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(<FieldRow {...{ field }} onChange={handleChange} isSavingEnabled={true} />)
);
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(
<FieldRow
{...{ field }}
onChange={handleChange}
unsavedChange={{ type: 'image', unsavedValue: userInputValues.image }}
isSavingEnabled={true}
/>
)
);
const link = getByTestId(`${DATA_TEST_SUBJ_CHANGE_LINK_PREFIX}-${field.id}`);
fireEvent.click(link);
waitFor(() => expect(getByAltText(field.id)).not.toBeInTheDocument());
});
});

View file

@ -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<T extends SettingType> = Pick<
FieldDefinition<T>,
| 'ariaAttributes'
| 'defaultValue'
| 'defaultValueDisplay'
| 'displayName'
| 'groupId'
| 'id'
| 'isCustom'
| 'isDefaultValue'
| 'isOverridden'
| 'name'
| 'savedValue'
| 'type'
| 'unsavedFieldId'
>;
/**
* Props for a {@link FieldRow} component.
*/
export interface FieldRowProps<T extends SettingType> {
export interface FieldRowProps {
/** The {@link FieldDefinition} corresponding the setting. */
field: Definition<SettingType>;
/** True if saving settings is enabled, false otherwise. */
isSavingEnabled: boolean;
/** The {@link OnChangeFn} handler. */
onChange: OnChangeFn<T>;
onChange: RowOnChangeFn<SettingType>;
/**
* 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<T>;
/** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */
unsavedChange?: UnsavedFieldChange<T>;
unsavedChange?: UnsavedFieldChange<SettingType>;
}
/**
* 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 = <T extends SettingType>(props: FieldRowProps<T>) => {
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<T>) => {
onChangeProp(name, changes);
// Create a ref for those input fields that use a `reset` handle.
const ref = useRef<ResetInputRef>(null);
// Route any change to the `onChange` handler, along with the field id.
const onChange: OnChangeFn<SettingType> = (update) => {
onChangeProp(id, update);
};
const resetField = () => {
const { defaultValue: unsavedValue } = field;
return onChange({ type, unsavedValue });
const onReset = () => {
ref.current?.reset();
const update = { type: field.type, unsavedValue: field.defaultValue };
if (hasUnsavedChange(field, update)) {
onChange(update);
} else {
onChange();
}
};
const onFieldChange = ({ isInvalid, error, value: unsavedValue }: OnChangeParams<T>) => {
if (error) {
isInvalid = true;
const onClear = () => {
if (ref.current) {
ref.current.reset();
}
const change = {
type,
isInvalid,
error,
};
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 = <FieldTitle {...{ field, unsavedChange }} />;
const description = <FieldDescription {...{ field }} />;
const description = <FieldDescription {...{ field, unsavedChange }} />;
const error = unsavedChange?.error;
const isInvalid = unsavedChange?.isInvalid;
let unsavedScreenReaderMessage = null;
const helpText = (
<FieldInputFooter
{...{
field,
unsavedChange,
isSavingEnabled,
onCancel: resetField,
onReset: resetField,
onChange: onFieldChange,
}}
/>
);
// Provide a screen-reader only message if there's an unsaved change.
if (unsavedChange) {
unsavedScreenReaderMessage = (
<EuiScreenReaderOnly>
@ -133,23 +144,25 @@ export const FieldRow = <T extends SettingType>(props: FieldRowProps<T>) => {
return (
<EuiErrorBoundary>
<EuiDescribedFormGroup
id={groupId}
fullWidth
css={cssFieldFormGroup}
fullWidth
id={groupId}
{...{ title, description }}
>
<EuiFormRow
fullWidth
hasChildLabel={!isImageFieldDefinition(field)}
label={id}
{...{ isInvalid, error, helpText }}
helpText={
<FieldInputFooter {...{ field, isSavingEnabled, onClear, onReset, unsavedChange }} />
}
{...{ isInvalid, error }}
>
<>
<FieldInput
isDisabled={!isSavingEnabled || isOverridden}
isSavingEnabled={isSavingEnabled && !isOverridden}
isInvalid={unsavedChange?.isInvalid}
onChange={onFieldChange}
{...{ field, unsavedChange }}
{...{ field, unsavedChange, ref, onChange }}
/>
{unsavedScreenReaderMessage}
</>

View file

@ -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(<ChangeImageLink {...defaultProps} />));
expect(container.firstChild).toBeNull();
});
it('renders with a saved value and no unsaved change', () => {
const { container } = render(
wrap(
<ChangeImageLink {...defaultProps} field={{ ...defaultProps.field, savedValue: IMAGE }} />
)
);
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(
<ChangeImageLink
{...defaultProps}
field={{ ...defaultProps.field, savedValue: IMAGE }}
unsavedChange={{ type: 'image', unsavedValue: undefined }}
/>
)
);
expect(container.firstChild).toBeNull();
});
it('renders when there is an unsaved change', () => {
const { container } = render(
wrap(
<ChangeImageLink
{...defaultProps}
field={{ ...defaultProps.field, savedValue: IMAGE }}
unsavedChange={{ type: 'image', unsavedValue: 'unsaved value' }}
/>
)
);
expect(container.firstChild).not.toBeNull();
});
it('renders nothing if the unsaved change value is undefined', () => {
const { container } = render(
wrap(
<ChangeImageLink
{...defaultProps}
unsavedChange={{ type: 'image', unsavedValue: undefined }}
/>
)
);
expect(container.firstChild).toBeNull();
});
it('renders an aria-label', () => {
const { getByLabelText } = render(
wrap(
<ChangeImageLink {...defaultProps} field={{ ...defaultProps.field, savedValue: IMAGE }} />
)
);
const link = getByLabelText('Change test');
expect(link).not.toBeNull();
});
});

View file

@ -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<T extends SettingType> = Pick<
FieldDefinition<T>,
'name' | 'defaultValue' | 'type' | 'savedValue' | 'savedValue' | 'ariaAttributes'
'id' | 'type' | 'savedValue' | 'ariaAttributes' | 'isOverridden'
>;
/**
* Props for a {@link ChangeImageLink} component.
*/
export interface ChangeImageLinkProps<T extends SettingType = 'image'> {
/** The {@link ImageFieldDefinition} corresponding the setting. */
field: Field<T>;
/** The {@link OnChangeFn} event handler. */
onChange: OnChangeFn<T>;
/** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */
unsavedChange?: UnsavedFieldChange<T>;
onClear: () => void;
}
/**
@ -41,46 +37,49 @@ export interface ChangeImageLinkProps<T extends SettingType = 'image'> {
*/
export const ChangeImageLink = <T extends SettingType>({
field,
onChange,
onClear,
unsavedChange,
}: ChangeImageLinkProps<T>) => {
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 (
<span>
<EuiLink
aria-label={i18n.translate('management.settings.field.changeImageLinkAriaLabel', {
defaultMessage: 'Change {ariaLabel}',
values: {
ariaLabel,
},
})}
onClick={() => onChange({ value: defaultValue })}
data-test-subj={`management-settings-changeImage-${name}`}
>
<FormattedMessage
id="management.settings.changeImageLinkText"
defaultMessage="Change image"
/>
</EuiLink>
</span>
);
}
return null;
// Use the type-guards on the definition and unsaved change.
return (
<span>
<EuiLink
aria-label={i18n.translate('management.settings.field.changeImageLinkAriaLabel', {
defaultMessage: 'Change {ariaLabel}',
values: {
ariaLabel,
},
})}
onClick={() => onClear()}
data-test-subj={`${DATA_TEST_SUBJ_CHANGE_LINK_PREFIX}-${field.id}`}
>
<FormattedMessage
id="management.settings.changeImageLinkText"
defaultMessage="Change image"
/>
</EuiLink>
</span>
);
};

View file

@ -7,3 +7,4 @@
*/
export { FieldInputFooter, type FieldInputFooterProps } from './input_footer';
export { InputResetLink, type FieldResetLinkProps } from './reset_link';

View file

@ -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};
}
`,
};
};

View file

@ -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<T extends SettingType> {
field: Field<T>;
/** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */
unsavedChange?: UnsavedFieldChange<T>;
/** The {@link OnChangeFn} handler. */
onChange: OnChangeFn<T>;
/** 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<T extends SettingType> {
}
export const FieldInputFooter = <T extends SettingType>({
isSavingEnabled,
field,
isSavingEnabled,
onClear,
onReset,
...props
unsavedChange,
}: FieldInputFooterProps<T>) => {
const { footerCSS } = useInputFooterStyles();
if (field.isOverridden) {
return <FieldOverriddenMessage {...{ field }} />;
}
if (isSavingEnabled) {
return (
<span data-test-subj={`${DATA_TEST_SUBJ_FOOTER_PREFIX}-${field.id}`}>
<FieldResetLink {...{ /* isLoading,*/ field, onReset }} />
<ChangeImageLink {...{ field, ...props }} />
<span css={footerCSS} data-test-subj={`${DATA_TEST_SUBJ_FOOTER_PREFIX}-${field.id}`}>
<InputResetLink {...{ field, onReset, unsavedChange }} />
<ChangeImageLink {...{ field, unsavedChange, onClear }} />
</span>
);
}

View file

@ -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<SettingType> = {
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(<InputResetLink {...defaultProps} />));
expect(container.firstChild).toBeNull();
});
it('renders a link to reset the field if there is a different saved value', () => {
const { getByText } = render(
wrap(
<InputResetLink {...defaultProps} field={{ ...defaultProps.field, savedValue: 'saved' }} />
)
);
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(
<InputResetLink
{...defaultProps}
unsavedChange={{ type: 'string', unsavedValue: 'unsaved' }}
/>
)
);
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(
<InputResetLink
{...defaultProps}
field={{ ...defaultProps.field, savedValue: 'saved' }}
unsavedChange={{ type: 'string', unsavedValue: 'default' }}
/>
)
);
expect(container.firstChild).toBeNull();
});
it('calls the onReset prop when the link is clicked', () => {
const { getByText } = render(
wrap(
<InputResetLink {...defaultProps} field={{ ...defaultProps.field, savedValue: 'saved' }} />
)
);
const link = getByText('Reset to default');
fireEvent.click(link);
expect(defaultProps.onReset).toHaveBeenCalled();
});
});

View file

@ -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<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<
FieldDefinition<T>,
'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<T>;
}
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 = <T extends SettingType>({
onReset: onClick,
field,
unsavedChange,
}: InputResetLinkProps<T>) => {
if (isFieldDefaultValue(field, unsavedChange) || field.isOverridden) {
return null;
}
const {
id,
ariaAttributes: { ariaLabel },
} = field;
return (
<EuiLink
aria-label={i18n.translate('management.settings.field.resetToDefaultLinkAriaLabel', {
defaultMessage: 'Reset {ariaLabel} to default',
values: {
ariaLabel,
},
})}
onClick={onClick}
data-test-subj={`${DATA_TEST_SUBJ_RESET_PREFIX}-${id}`}
>
<FormattedMessage
id="management.settings.resetToDefaultLinkText"
defaultMessage="Reset to default"
/>
</EuiLink>
);
};

View file

@ -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';

View file

@ -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(<ChangeImageLink {...defaultProps} field={{ ...defaultProps.field }} />)
);
expect(container.firstChild).toBeNull();
});
it('renders with a saved value and no unsaved change', () => {
const { container } = render(
wrap(
<ChangeImageLink {...defaultProps} field={{ ...defaultProps.field, savedValue: IMAGE }} />
)
);
expect(container.firstChild).not.toBeNull();
});
it('renders if there is a saved value and the unsaved value is undefined', () => {
const { container } = render(
wrap(
<ChangeImageLink
{...defaultProps}
field={{ ...defaultProps.field, savedValue: IMAGE }}
unsavedChange={{ type: 'image', unsavedValue: undefined }}
/>
)
);
expect(container.firstChild).not.toBeNull();
});
it('renders nothing when there is an unsaved change', () => {
const { container } = render(
wrap(
<ChangeImageLink
{...defaultProps}
field={{ ...defaultProps.field, savedValue: IMAGE }}
unsavedChange={{ type: 'image', unsavedValue: 'unsaved value' }}
/>
)
);
expect(container.firstChild).toBeNull();
});
it('renders an aria-label', () => {
const { getByLabelText } = render(
wrap(
<ChangeImageLink {...defaultProps} field={{ ...defaultProps.field, savedValue: IMAGE }} />
)
);
const link = getByLabelText('Change test');
expect(link).not.toBeNull();
});
});

View file

@ -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(<FieldResetLink {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('renders nothing if the field is already at its default value', () => {
const { container } = render(
wrap(
<FieldResetLink {...defaultProps} field={{ ...defaultProps.field, isDefaultValue: true }} />
)
);
expect(container.firstChild).toBeNull();
});
it('renders a link to reset the field if it is not at its default value', () => {
const { getByText } = render(wrap(<FieldResetLink {...defaultProps} />));
const link = getByText('Reset to default');
expect(link).toBeInTheDocument();
});
it('calls the onReset prop when the link is clicked', () => {
const { getByText } = render(wrap(<FieldResetLink {...defaultProps} />));
const link = getByText('Reset to default');
fireEvent.click(link);
expect(defaultProps.onReset).toHaveBeenCalled();
});
});

View file

@ -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<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<FieldDefinition<T>, '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 = <T extends SettingType>({
onReset,
field,
}: FieldResetLinkProps<T>) => {
if (field.isDefaultValue) {
return null;
}
const {
id,
ariaAttributes: { ariaLabel },
} = field;
return (
<span>
<EuiLink
aria-label={i18n.translate('management.settings.field.resetToDefaultLinkAriaLabel', {
defaultMessage: 'Reset {ariaLabel} to default',
values: {
ariaLabel,
},
})}
onClick={onReset}
data-test-subj={`${DATA_TEST_SUBJ_RESET_PREFIX}-${id}`}
>
<FormattedMessage
id="management.settings.resetToDefaultLinkText"
defaultMessage="Reset to default"
/>
</EuiLink>
&nbsp;&nbsp;&nbsp;
</span>
);
};

View file

@ -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"
}

View file

@ -17,9 +17,16 @@ import type { FieldRowServices, FieldRowKibanaDependencies, Services } from './t
const FieldRowContext = React.createContext<Services | null>(null);
/**
* React Provider that provides services to a {@link FieldRow} component and its dependents.
* Props for {@link FieldRowProvider}.
*/
export const FieldRowProvider: FC<FieldRowServices> = ({ 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;

View file

@ -22,7 +22,10 @@ import { FieldTitleUnsavedIcon } from './icon_unsaved';
*/
export interface TitleProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: FieldDefinition<T>;
field: Pick<
FieldDefinition<T>,
'displayName' | 'savedValue' | 'isCustom' | 'id' | 'type' | 'isOverridden'
>;
/** Emotion-based `css` for the root React element. */
css?: Interpolation<Theme>;
/** Classname for the root React element. */

View file

@ -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<T extends SettingType> = (id: string, change: UnsavedFieldChange<T>) => void;
export type RowOnChangeFn<T extends SettingType> = (
id: string,
change?: UnsavedFieldChange<T>
) => void;

View file

@ -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 = <T extends SettingType>(
const definition: FieldDefinition<T> = {
ariaAttributes: {
ariaLabel: getAriaLabel(name),
ariaLabel: name || getAriaLabel(name),
// ariaDescribedBy: unsavedChange.value ? `${groupId} ${unsavedId}` : undefined,
},
categories,
@ -133,7 +137,7 @@ export const getFieldDefinition = <T extends SettingType>(
groupId: `${name || id}-group`,
id,
isCustom: isCustom || false,
isDefaultValue: isEqual(defaultValue, setting.userValue),
isDefaultValue: isSettingDefaultValue(setting),
isOverridden: isOverridden || false,
isReadOnly: !!readonly,
metric,

View file

@ -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<string, UiSettingMetadata<SettingType>>,
client: IUiSettingsClient
): Array<FieldDefinition<SettingType>> =>
Object.entries(settings).map(([id, setting]) =>
getFieldDefinition({
id,
setting,
params: { isCustom: client.isCustom(id), isOverridden: client.isOverridden(id) },
})
);

View file

@ -30,3 +30,4 @@ export {
} from './is';
export { getFieldDefinition } from './get_definition';
export { getFieldDefinitions } from './get_definitions';

View file

@ -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"
}

View file

@ -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 = <T extends SettingType>(
setting: UiSettingMetadata<T>
): UiSettingMetadata<T> => {
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<T extends SettingType> {
value?: KnownTypeToValue<T> | null;
isInvalid?: boolean;
error?: string;
}
type OnChangeFn<T extends SettingType> = (params: OnChangeParams<T> | 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 = <T extends SettingType>(
baseSetting: UiSettingMetadata<T>,
params: { isCustom?: boolean; isOverridden?: boolean; isDeprecated?: boolean } = {}
): [FieldDefinition<T>, UnsavedFieldChange<T>, OnChangeFn<T>] => {
const setting = {
...expandSetting(baseSetting),
deprecation: params.isDeprecated
? { message: 'This setting is deprecated', docLinksKey: 'storybook' }
: undefined,
};
const field = getFieldDefinition<T>({
id: setting.name?.split(' ').join(':').toLowerCase() || setting.type,
setting,
params,
});
const { type, savedValue } = field;
const [unsavedChange, setUnsavedChange] = useState<UnsavedFieldChange<T>>({ type });
const onChange: OnChangeFn<T> = (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];
};

View file

@ -15,5 +15,7 @@
],
"kbn_references": [
"@kbn/management-settings-types",
"@kbn/core-ui-settings-browser",
"@kbn/management-settings-utilities",
]
}

View file

@ -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<T extends SettingType> = (change?: UnsavedFieldChange<T>) => void;

View file

@ -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"
}

View file

@ -12,7 +12,7 @@ import { KnownTypeToValue, SettingType } from './setting_type';
/**
* Creating this type based on {@link UiSettingsClientCommon} and exporting for ease.
*/
type UiSetting<T> = PublicUiSettingsParams & UserProvidedValues<T>;
export type UiSetting<T> = PublicUiSettingsParams & UserProvidedValues<T>;
/**
* This is an type-safe abstraction over the {@link UiSetting} type, whose fields

View file

@ -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<T extends SettingType> = Pick<FieldDefinition<T>, 'savedValue' | 'defaultValue'>;
type C<T extends SettingType> = UnsavedFieldChange<T>;
/**
* 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<S extends SettingType>(field: F<S>, change?: C<S>) {
const isUnsavedChange = hasUnsavedChange(field, change);
const value = isUnsavedChange
? change?.unsavedValue
: field.savedValue !== undefined && field.savedValue !== null
? field.savedValue
: field.defaultValue;
return [value, isUnsavedChange];
}

View file

@ -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);
});
});

View file

@ -22,14 +22,21 @@ import type {
* @param unsavedChange The unsaved change to compare.
*/
export const hasUnsavedChange = <T extends SettingType>(
field: Pick<FieldDefinition<T>, 'savedValue'>,
field: Pick<FieldDefinition<T>, 'savedValue' | 'defaultValue'>,
unsavedChange?: Pick<UnsavedFieldChange<T>, '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);
};

View file

@ -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';

View file

@ -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<T extends SettingType> = Pick<FieldDefinition<T>, 'savedValue' | 'defaultValue'>;
type C<T extends SettingType> = UnsavedFieldChange<T>;
/**
* 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<S extends SettingType>(field: F<S>, change?: C<S>): 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);
}

View file

@ -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<T extends SettingType> {
/** The {@link OnChangeFn} to invoke. */
onChange: OnChangeFn<T>;
/** The {@link FieldDefinition} to use to create an update. */
field: Pick<FieldDefinition<T>, '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 = <T extends SettingType>(params: UseUpdateParameters<T>): OnChangeFn<T> => {
const { onChange, field } = params;
return (update) => {
if (hasUnsavedChange(field, update)) {
onChange(update);
} else {
onChange();
}
};
};

View file

@ -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<T extends SettingType> = Pick<FieldDefinition<T>, 'savedValue' | 'defaultValue'>;
type C<T extends SettingType> = UnsavedFieldChange<T>;
/**
* 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<S extends SettingType>(field: F<S>, change: C<S>) {
const isUnsavedValue = hasUnsavedChange(field, change);
const value = isUnsavedValue
? change.unsavedValue
: field.savedValue !== undefined && field.savedValue !== null
? field.savedValue
: field.defaultValue;
return [value, isUnsavedValue];
}

View file

@ -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';

View file

@ -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 = <T extends SettingType>(
field: FieldDefinition<T>,
unsavedValue?: KnownTypeToValue<T> | null
) => {
const { savedValue } = field;
return unsavedValue !== undefined && !isEqual(unsavedValue, savedValue);
};

View file

@ -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"
}

View file

@ -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';

View file

@ -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<SettingType>,
userValue: Value = setting.userValue
) => {
const { value } = setting;
if (userValue === undefined || userValue === null) {
return true;
}
return isEqual(value, userValue);
};

View file

@ -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);
});
});

View file

@ -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<string, UiSetting<SettingType>>;
/**
* 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>): 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<string, UiSettingMetadata<SettingType>> => {
const normalizedSettings: Record<string, UiSettingMetadata<SettingType>> = {};
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;
};

View file

@ -7,4 +7,3 @@
*/
export { getDefaultValue, getUserValue, IMAGE } from './values';
export { useFieldDefinition } from './field_definition';