[serverless] Advanced Settings - Field Row components (#165286)

This commit is contained in:
Clint Andrew Hall 2023-09-11 18:47:22 -04:00 committed by GitHub
parent 7267efec49
commit f165e38845
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
121 changed files with 5992 additions and 9 deletions

5
.github/CODEOWNERS vendored
View file

@ -480,8 +480,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/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/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

@ -500,8 +500,13 @@
"@kbn/logstash-plugin": "link:x-pack/plugins/logstash",
"@kbn/management-cards-navigation": "link:packages/kbn-management/cards_navigation",
"@kbn/management-plugin": "link:src/plugins/management",
"@kbn/management-settings-components-field-input": "link:packages/kbn-management/settings/components/field_input",
"@kbn/management-settings-components-field-row": "link:packages/kbn-management/settings/components/field_row",
"@kbn/management-settings-field-definition": "link:packages/kbn-management/settings/field_definition",
"@kbn/management-settings-ids": "link:packages/kbn-management/settings/setting_ids",
"@kbn/management-settings-section-registry": "link:packages/kbn-management/settings/section_registry",
"@kbn/management-settings-types": "link:packages/kbn-management/settings/types",
"@kbn/management-settings-utilities": "link:packages/kbn-management/settings/utilities",
"@kbn/management-test-plugin": "link:test/plugin_functional/plugins/management_test_plugin",
"@kbn/mapbox-gl": "link:packages/kbn-mapbox-gl",
"@kbn/maps-custom-raster-source-plugin": "link:x-pack/examples/third_party_maps_source_example",

View file

@ -0,0 +1,12 @@
---
id: management/settings/components/fieldInput
slug: /management/settings/components/field-input
title: Management Settings Field Input Component
description: A package containing a component for rendering and manipulating the raw value of a UiSetting in Field Row.
tags: ['management', 'settings']
date: 2023-08-31
---
## Description
This package contains a component for rendering and manipulating the raw value of a UiSetting. It's used primarily by the `FieldRow` component to drive unsaved or reset changes.

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('Array Input', 'An input with an array value.');
export const ArrayInput = getInputStory('array' as const);

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('Boolean Input', 'An input with a boolean value.');
export const BooleanInput = getInputStory('boolean' as const);

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('Color Input', 'An input with a color value.');
export const ColorInput = getInputStory('color' as const);

View file

@ -0,0 +1,120 @@
/*
* 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 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';
import { FieldInputProvider } from '../services';
import { FieldInput as Component, FieldInput } from '../field_input';
import { InputProps, OnChangeFn } from '../types';
/**
* Props for a {@link FieldInput} Storybook story.
*/
export type StoryProps<T extends SettingType> = Pick<InputProps<T>, 'value' | 'isDisabled'>;
/**
* Interface defining available {@link https://storybook.js.org/docs/react/writing-stories/parameters parameters}
* for a {@link FieldInput} Storybook story.
*/
interface Params {
argTypes?: Record<string, unknown>;
settingFields?: Partial<UiSettingMetadata<UiSettingsType>>;
}
/**
* Interface defining types for available {@link https://storybook.js.org/docs/react/writing-stories/args arguments}
* for a {@link FieldInput} Storybook story.
*/
export interface Args {
/** True if the field is disabled, false otherwise. */
isDisabled: boolean;
}
/**
* 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.
* @param title The title displayed in the Storybook UI.
* @param description The description of the story.
* @returns A Storybook Story.
*/
export const getStory = (title: string, description: string) =>
({
title: `Settings/Field Input/${title}`,
description,
argTypes: {
isDisabled: {
name: 'Is field disabled?',
},
value: {
name: 'Current saved value',
},
},
decorators: [
(Story) => (
<FieldInputProvider showDanger={action('showDanger')}>
<EuiPanel style={{ width: 500 }}>
<Story />
</EuiPanel>
</FieldInputProvider>
),
],
} as ComponentMeta<typeof Component>);
/**
* Utility function for returning a {@link FieldInput} Storybook story.
* @param type The type of the UiSetting for this {@link FieldRow}.
* @param params Additional, optional {@link https://storybook.js.org/docs/react/writing-stories/parameters parameters}.
* @returns A Storybook Story.
*/
export const getInputStory = (type: SettingType, params: Params = {}) => {
const Story = ({ value, isDisabled = false }: StoryProps<typeof type>) => {
const setting: UiSettingMetadata<typeof type> = {
type,
value,
userValue: value,
...params.settingFields,
};
const [field, unsavedChange, onChangeFn] = useFieldDefinition(setting);
const onChange: OnChangeFn<typeof type> = (newChange) => {
onChangeFn(newChange);
};
return (
<FieldInput
{...{ field, isInvalid: unsavedChange.isInvalid, unsavedChange, onChange, isDisabled }}
/>
);
};
Story.args = {
value: getDefaultValue(type),
...params.argTypes,
...storyArgs,
};
return Story;
};

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('Image Input', 'An input with an image value.');
export const ImageInput = getInputStory('image' as const);

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('JSON Input', 'An input with a JSON value.');
export const JSONInput = getInputStory('json' as const);

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('Markdown Input', 'An input with a markdown value.');
export const MarkdownInput = getInputStory('markdown' as const);

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('Number Input', 'An input with a number value.');
export const NumberInput = getInputStory('number' as const);

View file

@ -0,0 +1,27 @@
/*
* 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 { getInputStory, getStory } from './common';
const argTypes = {
value: {
name: 'Current saved value',
control: {
type: 'select',
options: ['option1', 'option2', 'option3'],
},
},
};
const settingFields = {
optionLabels: { option1: 'Option 1', option2: 'Option 2', option3: 'Option 3' },
options: ['option1', 'option2', 'option3'],
};
export default getStory('Select Input', 'An input with multiple values.');
export const SelectInput = getInputStory('select' as const, { argTypes, settingFields });

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.
*/
import { getInputStory, getStory } from './common';
export default getStory('String Input', 'An input with a string value.');
export const StringInput = getInputStory('string' as const);

View file

@ -0,0 +1,108 @@
/*
* 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.
*/
// This component was ported directly from `advancedSettings`, and hasn't really
// been vetted. It has, however, been refactored to be compliant with our
// current standards.
//
// @see src/plugins/advanced_settings/public/management_app/components/field/field_code_editor.tsx
//
import React, { useCallback } from 'react';
import { monaco, XJsonLang } from '@kbn/monaco';
import {
CodeEditor as KibanaReactCodeEditor,
MarkdownLang,
type CodeEditorProps as KibanaReactCodeEditorProps,
} from '@kbn/kibana-react-plugin/public';
type Props = Pick<KibanaReactCodeEditorProps, 'aria-label' | 'value' | 'onChange'>;
type Options = KibanaReactCodeEditorProps['options'];
export interface CodeEditorProps extends Props {
type: 'markdown' | 'json';
isReadOnly: boolean;
name: string;
}
const MIN_DEFAULT_LINES_COUNT = 6;
const MAX_DEFAULT_LINES_COUNT = 30;
export const CodeEditor = ({ onChange, type, isReadOnly, name, ...props }: CodeEditorProps) => {
// setting editor height based on lines height and count to stretch and fit its content
const setEditorCalculatedHeight = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
const editorElement = editor.getDomNode();
if (!editorElement) {
return;
}
const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight);
let lineCount = editor.getModel()?.getLineCount() || MIN_DEFAULT_LINES_COUNT;
if (lineCount < MIN_DEFAULT_LINES_COUNT) {
lineCount = MIN_DEFAULT_LINES_COUNT;
} else if (lineCount > MAX_DEFAULT_LINES_COUNT) {
lineCount = MAX_DEFAULT_LINES_COUNT;
}
const height = lineHeight * lineCount;
editorElement.id = name;
editorElement.style.height = `${height}px`;
editor.layout();
},
[name]
);
const trimEditorBlankLines = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => {
const editorModel = editor.getModel();
if (!editorModel) {
return;
}
const trimmedValue = editorModel.getValue().trim();
editorModel.setValue(trimmedValue);
}, []);
const editorDidMount = useCallback(
(editor) => {
setEditorCalculatedHeight(editor);
editor.onDidChangeModelContent(() => {
setEditorCalculatedHeight(editor);
});
editor.onDidBlurEditorWidget(() => {
trimEditorBlankLines(editor);
});
},
[setEditorCalculatedHeight, trimEditorBlankLines]
);
const options: Options = {
readOnly: isReadOnly,
lineNumbers: 'off',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: false,
tabSize: 2,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
wordWrap: 'on',
wrappingIndent: 'indent',
};
return (
<KibanaReactCodeEditor
{...{ onChange, editorDidMount, options, ...props }}
languageId={type === 'json' ? XJsonLang.ID : MarkdownLang}
width="100%"
/>
);
};

View file

@ -0,0 +1,201 @@
/*
* 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 { FieldInput, FieldInputProps } from './field_input';
import { FieldDefinition, SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
import { TEST_SUBJ_PREFIX_FIELD } from './input';
import { wrap } from './mocks';
import { CodeEditorProps } from './code_editor';
const name = 'test';
jest.mock('./code_editor', () => ({
CodeEditor: ({ value, onChange }: CodeEditorProps) => (
<input
data-test-subj={`management-settings-editField-test`}
type="text"
value={String(value)}
onChange={(e) => {
if (onChange) {
onChange(e.target.value, e as any);
}
}}
/>
),
}));
describe('FieldInput', () => {
const getDefaultProps = (type: SettingType): FieldInputProps<typeof type> => {
let options;
if (type === 'select') {
options = {
labels: {
option1: 'Option 1',
option2: 'Option 2',
option3: 'Option 3',
},
values: ['option1', 'option2', 'option3'],
};
}
const props: FieldInputProps<typeof type> = {
field: {
id: 'test',
name,
type,
ariaAttributes: {
ariaLabel: 'Test',
},
options,
} as FieldDefinition<typeof type>,
onChange: jest.fn(),
};
return props;
};
it('renders without errors', () => {
const { container } = render(wrap(<FieldInput {...getDefaultProps('string')} />));
expect(container).toBeInTheDocument();
});
it('renders a TextInput for a string field', () => {
const props = getDefaultProps('string');
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('renders a NumberInput for a number field', () => {
const props = getDefaultProps('number');
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('renders a BooleanInput for a boolean field', () => {
const props = getDefaultProps('boolean');
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('renders a ColorInput for a color field', () => {
const props = getDefaultProps('color');
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`euiColorPickerAnchor ${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('renders a ImageInput for a color field', () => {
const props = getDefaultProps('image');
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('renders a JsonInput for a json field', () => {
const props = getDefaultProps('json');
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('renders a MarkdownInput for a markdown field', () => {
const props = getDefaultProps('markdown');
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('renders a SelectInput for an select field', () => {
const props = {
...getDefaultProps('select'),
value: 'option2',
};
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeInTheDocument();
});
it('calls the onChange prop when the value changes', () => {
const props = getDefaultProps('string');
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' });
});
it('disables the input when isDisabled prop is true', () => {
const props = getDefaultProps('string');
const { getByTestId } = render(wrap(<FieldInput {...props} isDisabled />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
expect(input).toBeDisabled();
});
it('throws an error if the field and unsavedChange types do not match', () => {
const consoleMock = jest.spyOn(console, 'error').mockImplementation(() => {});
[
'array',
'boolean',
'color',
'image',
'json',
'markdown',
'string',
'select',
'undefined',
].forEach((type) => {
expect(() =>
render(
wrap(
<FieldInput
{...getDefaultProps(type as SettingType)}
unsavedChange={{ type: 'number', value: 123 } as UnsavedFieldChange<any>}
/>
)
)
).toThrowError(`Unsaved change for ${type} mismatch: number`);
});
expect(() =>
render(
wrap(
<FieldInput
{...getDefaultProps('number')}
unsavedChange={{ type: 'string', value: 1 } as UnsavedFieldChange<any>}
/>
)
)
).toThrowError(`Unsaved change for number mismatch: string`);
consoleMock.mockRestore();
});
it('throws an error if type is unknown or incompatible', () => {
const consoleMock = jest.spyOn(console, 'error').mockImplementation(() => {});
const defaultProps = getDefaultProps('string');
const props = {
...defaultProps,
field: {
...defaultProps.field,
type: 'foobar',
},
} as unknown as FieldInputProps<'string'>;
expect(() => render(wrap(<FieldInput {...props} />))).toThrowError(
'Unknown or incompatible field type: foobar'
);
consoleMock.mockRestore();
});
});

View file

@ -0,0 +1,250 @@
/*
* 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 type {
FieldDefinition,
SettingType,
UnsavedFieldChange,
} from '@kbn/management-settings-types';
import {
isArrayFieldDefinition,
isBooleanFieldDefinition,
isColorFieldDefinition,
isImageFieldDefinition,
isJsonFieldDefinition,
isMarkdownFieldDefinition,
isNumberFieldDefinition,
isSelectFieldDefinition,
isStringFieldDefinition,
isUndefinedFieldDefinition,
} from '@kbn/management-settings-field-definition';
import {
isArrayFieldUnsavedChange,
isBooleanFieldUnsavedChange,
isColorFieldUnsavedChange,
isImageFieldUnsavedChange,
isJsonFieldUnsavedChange,
isMarkdownFieldUnsavedChange,
isNumberFieldUnsavedChange,
isSelectFieldUnsavedChange,
isStringFieldUnsavedChange,
isUndefinedFieldUnsavedChange,
} from '@kbn/management-settings-field-definition/is';
import { getInputValue } from '@kbn/management-settings-utilities';
import {
BooleanInput,
CodeEditorInput,
ColorPickerInput,
ImageInput,
NumberInput,
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>;
/** 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 value within the input is invalid, false otherwise. */
isInvalid?: boolean;
}
/**
* Build and return an `Error` if the type of the {@link UnsavedFieldChange} does not
* match the type of the {@link FieldDefinition}.
*/
const getMismatchError = (type: SettingType, unsavedType?: SettingType) =>
new Error(`Unsaved change for ${type} mismatch: ${unsavedType}`);
/**
* An input that allows one to change a setting in Kibana.
*
* @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;
const inputProps = {
...ariaAttributes,
id,
isDisabled,
isInvalid,
name,
};
// These checks might seem excessive or redundant, but they are necessary to ensure that
// the types are honored correctly using type guards. These checks get compiled down to
// checks against the `type` property-- which we were doing in the previous code, albeit
// in an unenforceable way.
//
// Based on the success of a check, we can render the `FieldInput` in a indempotent and
// type-safe way.
//
if (isArrayFieldDefinition(field)) {
// If the composing component mistakenly provides an incompatible `UnsavedFieldChange`,
// we can throw an `Error`. We might consider switching to a `console.error` and not
// rendering the input, but that might be less helpful.
if (!isArrayFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
// This is a safe cast because we've already checked that the type is correct in both
// the `FieldDefinition` and the `UnsavedFieldChange`... no need for a further
// type guard.
const onChange = onChangeProp as OnChangeFn<'array'>;
return <ArrayInput {...{ ...inputProps, onChange, value }} />;
}
if (isBooleanFieldDefinition(field)) {
if (!isBooleanFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'boolean'>;
return <BooleanInput {...{ ...inputProps, onChange, value }} />;
}
if (isColorFieldDefinition(field)) {
if (!isColorFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'color'>;
return <ColorPickerInput {...{ ...inputProps, onChange, value }} />;
}
if (isImageFieldDefinition(field)) {
if (!isImageFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value, unsaved] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'image'>;
return (
<ImageInput
{...{ ...inputProps, onChange, value }}
isDefaultValue={field.isDefaultValue}
hasChanged={unsaved}
/>
);
}
if (isJsonFieldDefinition(field)) {
if (!isJsonFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'json'>;
return (
<CodeEditorInput
{...{ ...inputProps, onChange, value }}
type="json"
defaultValue={field.savedValue || ''}
/>
);
}
if (isMarkdownFieldDefinition(field)) {
if (!isMarkdownFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'markdown'>;
return (
<CodeEditorInput
{...{ ...inputProps, onChange, value }}
type="markdown"
defaultValue={field.savedValue || ''}
/>
);
}
if (isNumberFieldDefinition(field)) {
if (!isNumberFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'number'>;
return <NumberInput {...{ ...inputProps, onChange, value }} />;
}
if (isSelectFieldDefinition(field)) {
if (!isSelectFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'select'>;
const {
options: { values: optionValues, labels: optionLabels },
} = field;
return <SelectInput {...{ ...inputProps, onChange, optionLabels, optionValues, value }} />;
}
if (isStringFieldDefinition(field)) {
if (!isStringFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
const onChange = onChangeProp as OnChangeFn<'string'>;
return <TextInput {...{ ...inputProps, onChange, value }} />;
}
if (isUndefinedFieldDefinition(field)) {
if (!isUndefinedFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const [value] = getInputValue(field, unsavedChange);
return <TextInput {...{ ...(inputProps as unknown as TextInputProps), value }} />;
}
throw new Error(`Unknown or incompatible field type: ${field.type}`);
};

View file

@ -0,0 +1,16 @@
/*
* 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 { FieldInput, type FieldInputProps } from './field_input';
export type {
FieldInputKibanaDependencies,
FieldInputServices,
OnChangeFn,
OnChangeParams,
} from './types';

View file

@ -0,0 +1,74 @@
/*
* 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 { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ArrayInput } from './array_input';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
import { wrap } from '../mocks';
const name = 'Some array field';
const id = 'some:array:field';
describe('ArrayInput', () => {
const defaultProps = {
id,
name,
ariaLabel: 'Test',
onChange: jest.fn(),
value: ['foo', 'bar'],
};
beforeEach(() => {
defaultProps.onChange.mockClear();
});
it('renders without errors', () => {
const { container } = render(wrap(<ArrayInput {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('renders an array of strings', () => {
render(wrap(<ArrayInput {...defaultProps} />));
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toHaveValue('foo, bar');
});
it('formats array when blurred', () => {
render(wrap(<ArrayInput {...defaultProps} />));
const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.focus(input);
userEvent.type(input, ',baz');
expect(input).toHaveValue('foo, bar,baz');
input.blur();
expect(input).toHaveValue('foo, bar, baz');
});
it('only calls onChange when blurred ', () => {
render(wrap(<ArrayInput {...defaultProps} />));
const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.focus(input);
userEvent.type(input, ',baz');
expect(input).toHaveValue('foo, bar,baz');
expect(defaultProps.onChange).not.toHaveBeenCalled();
act(() => {
input.blur();
});
expect(defaultProps.onChange).toHaveBeenCalledWith({ value: ['foo', 'bar', 'baz'] });
});
it('disables the input when isDisabled prop is true', () => {
const { getByTestId } = render(wrap(<ArrayInput {...defaultProps} isDisabled />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});
});

View file

@ -0,0 +1,62 @@
/*
* 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, { useEffect, useState } from 'react';
import { EuiFieldText } from '@elastic/eui';
import { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
/**
* Props for an {@link ArrayFieldInput} component.
*/
export type ArrayInputProps = InputProps<'array'>;
const REGEX = /,\s+/g;
/**
* Component for manipulating an `array` field.
*/
export const ArrayInput = ({
id,
name,
onChange: onChangeProp,
ariaLabel,
isDisabled = false,
value: valueProp,
ariaDescribedBy,
}: ArrayInputProps) => {
const [value, setValue] = useState(valueProp?.join(', '));
useEffect(() => {
setValue(valueProp?.join(', '));
}, [valueProp]);
// In the past, each keypress would invoke the `onChange` callback. This
// is likely wasteful, so we've switched it to `onBlur` instead.
const onBlur = (event: React.ChangeEvent<HTMLInputElement>) => {
const blurValue = event.target.value
.replace(REGEX, ',')
.split(',')
.filter((v) => v !== '');
onChangeProp({ value: blurValue });
setValue(blurValue.join(', '));
};
return (
<EuiFieldText
fullWidth
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
onChange={(event) => setValue(event.target.value)}
{...{ name, onBlur, value }}
/>
);
};

View file

@ -0,0 +1,57 @@
/*
* 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 { act, fireEvent, render, screen } from '@testing-library/react';
import { BooleanInput } from './boolean_input';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
import { wrap } from '../mocks';
const name = 'Some boolean field';
const id = 'some:boolean:field';
describe('BooleanInput', () => {
const defaultProps = {
id,
name,
ariaLabel: name,
onChange: jest.fn(),
};
beforeEach(() => {
defaultProps.onChange.mockClear();
});
it('renders true', () => {
render(wrap(<BooleanInput value={true} {...defaultProps} />));
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked();
});
it('renders false', () => {
render(wrap(<BooleanInput value={false} {...defaultProps} />));
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).not.toBeChecked();
});
it('calls onChange when toggled', () => {
render(wrap(<BooleanInput value={true} {...defaultProps} />));
const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(defaultProps.onChange).not.toHaveBeenCalled();
act(() => {
fireEvent.click(input);
});
expect(defaultProps.onChange).toBeCalledWith({ value: false });
act(() => {
fireEvent.click(input);
});
});
});

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 React from 'react';
import { EuiSwitch, EuiSwitchProps } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
/**
* Props for a {@link BooleanInput} component.
*/
export type BooleanInputProps = InputProps<'boolean'>;
/**
* Component for manipulating a `boolean` field.
*/
export const BooleanInput = ({
id,
ariaDescribedBy,
ariaLabel,
isDisabled: disabled = false,
name,
onChange: onChangeProp,
value,
}: BooleanInputProps) => {
const onChange: EuiSwitchProps['onChange'] = (event) =>
onChangeProp({ value: event.target.checked });
return (
<EuiSwitch
label={
!!value ? (
<FormattedMessage id="management.settings.onLabel" defaultMessage="On" />
) : (
<FormattedMessage id="management.settings.offLabel" defaultMessage="Off" />
)
}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
checked={!!value}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
{...{ disabled, name, onChange }}
/>
);
};

View file

@ -0,0 +1,103 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { SettingType } from '@kbn/management-settings-types';
import { CodeEditor } from '../code_editor';
import type { InputProps, OnChangeFn } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
type Type = Extract<SettingType, 'json' | 'markdown'>;
/**
* Props for a {@link CodeEditorInput} component.
*/
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`
*/
type: Type;
}
/**
* Component for manipulating a `json` or `markdown` field.
*
* TODO: clintandrewhall - `kibana_react` `CodeEditor` does not support `disabled`.
*/
export const CodeEditorInput = ({
ariaDescribedBy,
ariaLabel,
defaultValue,
id,
isDisabled = false,
onChange: onChangeProp,
type,
value: valueProp = '',
}: CodeEditorInputProps) => {
const onChange = (newValue: string) => {
let newUnsavedValue;
let errorParams = {};
switch (type) {
case 'json':
const isJsonArray = Array.isArray(JSON.parse(defaultValue || '{}'));
newUnsavedValue = newValue || (isJsonArray ? '[]' : '{}');
try {
JSON.parse(newUnsavedValue);
} catch (e) {
errorParams = {
error: i18n.translate('management.settings.field.codeEditorSyntaxErrorMessage', {
defaultMessage: 'Invalid JSON syntax',
}),
isInvalid: true,
};
}
break;
default:
newUnsavedValue = newValue;
}
// TODO: clintandrewhall - should we make this onBlur instead of onChange?
onChangeProp({
value: newUnsavedValue,
...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;
return (
<div>
<CodeEditor
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
isReadOnly={isDisabled}
name={`${TEST_SUBJ_PREFIX_FIELD}-${id}-editor`}
{...{ onChange, type, value }}
/>
</div>
);
};

View file

@ -0,0 +1,50 @@
/*
* 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 { ColorPickerInput } 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',
};
it('renders without errors', () => {
const { container } = render(wrap(<ColorPickerInput {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByRole } = render(wrap(<ColorPickerInput {...defaultProps} />));
const input = getByRole('textbox');
expect(input).toHaveValue('#000000');
});
it('calls the onChange prop when the value changes', () => {
const { getByRole } = render(wrap(<ColorPickerInput {...defaultProps} />));
const input = getByRole('textbox');
const newValue = '#ffffff';
fireEvent.change(input, { target: { value: newValue } });
expect(defaultProps.onChange).toHaveBeenCalledWith({ value: newValue });
});
it('disables the input when isDisabled prop is true', () => {
const { getByRole } = render(wrap(<ColorPickerInput {...defaultProps} isDisabled />));
const input = getByRole('textbox');
expect(input).toBeDisabled();
});
});

View file

@ -0,0 +1,57 @@
/*
* 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 { EuiColorPicker, EuiColorPickerProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
/**
* Props for a {@link ColorPickerInput} component.
*/
export type ColorPickerInputProps = InputProps<'color'>;
const invalidMessage = i18n.translate('management.settings.fieldInput.color.invalidMessage', {
defaultMessage: 'Provide a valid color value',
});
/**
* Component for manipulating a `color` field.
*/
export const ColorPickerInput = ({
ariaDescribedBy,
ariaLabel,
id,
isDisabled = false,
isInvalid = false,
onChange: onChangeProp,
name,
value: color,
}: ColorPickerInputProps) => {
const onChange: EuiColorPickerProps['onChange'] = (newColor, { isValid }) => {
if (newColor !== '' && !isValid) {
onChangeProp({ value: newColor, isInvalid: true, error: invalidMessage });
} else {
onChangeProp({ value: newColor });
}
};
return (
<EuiColorPicker
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
format="hex"
isInvalid={isInvalid}
{...{ name, color, onChange }}
/>
);
};

View file

@ -0,0 +1,55 @@
/*
* 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 { ImageInput } from './image_input';
import { wrap } from '../mocks';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
import { act } from 'react-dom/test-utils';
import userEvent from '@testing-library/user-event';
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,
};
it('renders without errors', () => {
const { container } = render(wrap(<ImageInput {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('calls the onChange prop when a file is selected', async () => {
const { getByTestId } = render(wrap(<ImageInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`) as HTMLInputElement;
const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
act(() => {
userEvent.upload(input, [file]);
});
expect(input.files?.length).toBe(1);
// This doesn't work for some reason.
// expect(defaultProps.onChange).toHaveBeenCalledWith({ value: file });
});
it('disables the input when isDisabled prop is true', () => {
const { getByTestId } = render(wrap(<ImageInput {...defaultProps} isDisabled />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});
});

View file

@ -0,0 +1,108 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiFilePicker, EuiImage } from '@elastic/eui';
import type { InputProps } from '../types';
import { useServices } from '../services';
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;
}
const getImageAsBase64 = async (file: Blob): Promise<string | ArrayBuffer> => {
const reader = new FileReader();
reader.readAsDataURL(file);
return new Promise((resolve, reject) => {
reader.onload = () => {
resolve(reader.result!);
};
reader.onerror = (err) => {
reject(err);
};
});
};
const errorMessage = i18n.translate('management.settings.field.imageChangeErrorMessage', {
defaultMessage: 'Image could not be saved',
});
/**
* Component for manipulating an `image` field.
*/
export const ImageInput = React.forwardRef<EuiFilePicker, ImageInputProps>(
(
{
ariaDescribedBy,
ariaLabel,
id,
isDisabled,
isDefaultValue,
onChange: onChangeProp,
name,
value,
hasChanged,
},
ref
) => {
const { showDanger } = useServices();
const onChange = async (files: FileList | null) => {
if (files === null || !files.length) {
onChangeProp({ value: '' });
return null;
}
const file = files[0];
try {
let base64Image = '';
if (file instanceof File) {
base64Image = String(await getImageAsBase64(file));
}
onChangeProp({ value: base64Image });
} catch (err) {
showDanger(errorMessage);
onChangeProp({ value: '', error: errorMessage });
}
};
const a11yProps = {
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
};
// TODO: this check will be a bug, if a default image is ever actually
// defined in Kibana.
if (value && !isDefaultValue && !hasChanged) {
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}
fullWidth
{...{ onChange, ref, ...a11yProps }}
/>
);
}
}
);

View file

@ -0,0 +1,18 @@
/*
* 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 { ArrayInput, type ArrayInputProps } from './array_input';
export { CodeEditorInput, type CodeEditorInputProps } from './code_editor_input';
export { BooleanInput, type BooleanInputProps } from './boolean_input';
export { ColorPickerInput, type ColorPickerInputProps } from './color_picker_input';
export { ImageInput, type ImageInputProps } from './image_input';
export { NumberInput, type NumberInputProps } from './number_input';
export { SelectInput, type SelectInputProps } from './select_input';
export { TextInput, type TextInputProps } from './text_input';
export const TEST_SUBJ_PREFIX_FIELD = 'management-settings-editField';

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 React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { CodeEditorInput } 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 initialValue = '{"foo":"bar"}';
jest.mock('../code_editor', () => ({
CodeEditor: ({ value, onChange }: CodeEditorProps) => (
<input
data-test-subj="management-settings-editField-some:json:field"
type="text"
value={String(value)}
onChange={(e) => {
if (onChange) {
onChange(e.target.value, e as any);
}
}}
/>
),
}));
describe('JsonEditorInput', () => {
const defaultProps = {
id,
name,
ariaLabel: 'Test',
onChange: jest.fn(),
value: initialValue,
type: 'json' as 'json',
};
beforeEach(() => {
defaultProps.onChange.mockClear();
});
it('renders without errors', () => {
const { container } = render(<CodeEditorInput {...defaultProps} />);
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(initialValue);
});
it('calls the onChange prop when the object value changes', () => {
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"}' });
});
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: '{}' });
});
it('calls the onChange prop with an error when the object value changes to invalid JSON', () => {
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"}',
error: 'Invalid JSON syntax',
isInvalid: true,
});
});
it('calls the onChange prop when the array value changes', () => {
const props = { ...defaultProps, defaultValue: '["bar", "foo"]', value: undefined };
const { getByTestId } = render(<CodeEditorInput {...props} />);
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"]' })
);
});
it('calls the onChange prop when the array value changes with no value', () => {
const props = {
...defaultProps,
defaultValue: '["bar", "foo"]',
value: '["bar", "foo"]',
};
const { getByTestId } = render(<CodeEditorInput {...props} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({ value: '[]' });
});
it('calls the onChange prop with an array when the array value changes to invalid JSON', () => {
const props = { ...defaultProps, defaultValue: '["bar", "foo"]', value: undefined };
const { getByTestId } = render(<CodeEditorInput {...props} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '["bar", "foo" | "baz"]' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({
value: '["bar", "foo" | "baz"]',
error: 'Invalid JSON syntax',
isInvalid: true,
});
});
});

View file

@ -0,0 +1,62 @@
/*
* 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 { CodeEditorInput } 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 initialValue = '# A Markdown Title';
jest.mock('../code_editor', () => ({
CodeEditor: ({ value, onChange }: CodeEditorProps) => (
<input
data-test-subj="management-settings-editField-some:json:field"
type="text"
value={String(value)}
onChange={(e) => {
if (onChange) {
onChange(e.target.value, e as any);
}
}}
/>
),
}));
describe('JsonEditorInput', () => {
const defaultProps = {
id,
name,
ariaLabel: 'Test',
onChange: jest.fn(),
value: initialValue,
type: 'markdown' as 'markdown',
};
it('renders without errors', () => {
const { container } = render(<CodeEditorInput {...defaultProps} />);
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(initialValue);
});
it('calls the onChange prop when the value changes', () => {
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' });
});
});

View file

@ -0,0 +1,56 @@
/*
* 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, waitFor } from '@testing-library/react';
import { NumberInput } from './number_input';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
import { wrap } from '../mocks';
const name = 'Some number field';
const id = 'some:number:field';
describe('NumberInput', () => {
const defaultProps = {
id,
name,
ariaLabel: 'Test',
onChange: jest.fn(),
value: 12345,
};
it('renders without errors', () => {
const { container } = render(wrap(<NumberInput {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(defaultProps.value);
});
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 });
});
it('disables the input when isDisabled prop is true', () => {
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} isDisabled />));
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 input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
waitFor(() => expect(input).toHaveValue(undefined));
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { EuiFieldNumber } from '@elastic/eui';
import { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
/**
* Props for a {@link NumberInput} component.
*/
export type NumberInputProps = InputProps<'number'>;
/**
* Component for manipulating a `number` field.
*/
export const NumberInput = ({
ariaDescribedBy,
ariaLabel,
id,
isDisabled: disabled = false,
name,
onChange: onChangeProp,
value: valueProp,
}: NumberInputProps) => {
const onChange = (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeProp({ value: Number(event.target.value) });
// 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;
return (
<EuiFieldNumber
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
fullWidth
{...{ disabled, name, value, onChange }}
/>
);
};

View file

@ -0,0 +1,90 @@
/*
* 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 { SelectInput, SelectInputProps } from './select_input';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
import { wrap } from '../mocks';
const name = 'Some select field';
const id = 'some:select:field';
describe('SelectInput', () => {
const defaultProps = {
id,
name,
ariaLabel: 'Test',
onChange: jest.fn(),
optionLabels: {
option1: 'Option 1',
option2: 'Option 2',
option3: 'Option 3',
},
optionValues: ['option1', 'option2', 'option3'],
value: 'option2',
};
it('renders without errors', () => {
const { container } = render(wrap(<SelectInput {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByTestId } = render(wrap(<SelectInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue('option2');
});
it('calls the onChange prop when the value changes', () => {
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' });
});
it('disables the input when isDisabled prop is true', () => {
const { getByTestId } = render(wrap(<SelectInput {...defaultProps} isDisabled />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});
it('throws when optionValues is not provided', () => {
const consoleMock = jest.spyOn(console, 'error').mockImplementation(() => {});
const props = {
...defaultProps,
optionLabels: undefined as any,
optionValues: [],
} as SelectInputProps;
expect(() => render(wrap(<SelectInput {...props} />))).toThrowError(
'non-empty `optionValues` are required for `SelectInput`.'
);
consoleMock.mockRestore();
});
it('recovers if optionLabel is missing', () => {
const props = {
...defaultProps,
optionLabels: {},
} as SelectInputProps;
const { container } = render(wrap(<SelectInput {...props} />));
expect(container).toBeInTheDocument();
});
it('recovers if value is null', () => {
const props = {
...defaultProps,
value: null,
} as SelectInputProps;
const { container } = render(wrap(<SelectInput {...props} />));
expect(container).toBeInTheDocument();
});
});

View file

@ -0,0 +1,71 @@
/*
* 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, { useMemo } from 'react';
import { EuiSelect } from '@elastic/eui';
import { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
/**
* Props for a {@link SelectInput} component.
*/
export interface SelectInputProps extends InputProps<'select'> {
/** Specify the option labels to their values. */
optionLabels: Record<string, string | number>;
/** Specify the option values. */
optionValues: Array<string | number>;
}
/**
* Component for manipulating a `select` field.
*/
export const SelectInput = ({
ariaDescribedBy,
ariaLabel,
id,
isDisabled = false,
onChange: onChangeProp,
optionLabels = {},
optionValues: optionsProp,
value: valueProp,
}: SelectInputProps) => {
if (optionsProp.length === 0) {
throw new Error('non-empty `optionValues` are required for `SelectInput`.');
}
const options = useMemo(
() =>
optionsProp?.map((option) => ({
text: optionLabels.hasOwnProperty(option) ? optionLabels[option] : option,
value: option,
})),
[optionsProp, optionLabels]
);
const onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
onChangeProp({ value: event.target.value });
};
// 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;
return (
<EuiSelect
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
fullWidth
{...{ onChange, options, value }}
/>
);
};

View file

@ -0,0 +1,50 @@
/*
* 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 { TextInput } 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',
};
it('renders without errors', () => {
const { container } = render(<TextInput {...defaultProps} />);
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByTestId } = render(<TextInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue('initial value');
});
it('calls the onChange prop when the value changes', () => {
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' });
});
it('disables the input when isDisabled prop is true', () => {
const { getByTestId } = render(<TextInput {...defaultProps} isDisabled />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toBeDisabled();
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 { EuiFieldText } from '@elastic/eui';
import { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
/**
* Props for a {@link TextInput} component.
*/
export type TextInputProps = InputProps<'string'>;
/**
* Component for manipulating a `string` field.
*/
export const TextInput = ({
name,
onChange: onChangeProp,
ariaLabel,
id,
isDisabled = false,
value: valueProp,
ariaDescribedBy,
}: TextInputProps) => {
const value = valueProp || '';
const onChange = (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeProp({ value: event.target.value });
return (
<EuiFieldText
fullWidth
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
disabled={isDisabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
{...{ name, onChange, value }}
/>
);
};

View file

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

View file

@ -0,0 +1,55 @@
/*
* 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, { ReactChild } from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { I18nStart } from '@kbn/core-i18n-browser';
import { FieldInputProvider } from '../services';
import { FieldInputServices } from '../types';
const createRootMock = () => {
const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
const theme = themeServiceMock.createStartContract();
return {
i18n,
theme,
};
};
export const createFieldInputServicesMock = (): FieldInputServices => ({
showDanger: jest.fn(),
});
export const TestWrapper = ({
children,
services = createFieldInputServicesMock(),
}: {
children: ReactChild;
services?: FieldInputServices;
}) => {
return (
<KibanaRootContextProvider {...createRootMock()}>
<FieldInputProvider {...services}>{children}</FieldInputProvider>
</KibanaRootContextProvider>
);
};
export const wrap = (
component: JSX.Element,
services: FieldInputServices = createFieldInputServicesMock()
) => (
<KibanaRootContextProvider {...createRootMock()}>
<TestWrapper {...services}>{component}</TestWrapper>
</KibanaRootContextProvider>
);

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 { TestWrapper, createFieldInputServicesMock, wrap } from './context.mock';
export type { FieldInputProvider } from '../services';
export type { FieldInputServices } from '../types';

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/management-settings-components-field-input",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,58 @@
/*
* 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, { FC, useContext } from 'react';
import type { FieldInputServices, FieldInputKibanaDependencies } from './types';
const FieldInputContext = React.createContext<FieldInputServices | null>(null);
/**
* React Provider that provides services to a {@link FieldInput} component and its dependents.
*/
export const FieldInputProvider: FC<FieldInputServices> = ({ children, ...services }) => {
// Typescript types are widened to accept more than what is needed. Take only what is necessary
// so the context remains clean.
const { showDanger } = services;
return <FieldInputContext.Provider value={{ showDanger }}>{children}</FieldInputContext.Provider>;
};
/**
* Kibana-specific Provider that maps Kibana plugins and services to a {@link FieldInputProvider}.
*/
export const FieldInputKibanaProvider: FC<FieldInputKibanaDependencies> = ({
children,
toasts,
}) => {
return (
<FieldInputContext.Provider
value={{
showDanger: (message) => toasts.addDanger(message),
}}
>
{children}
</FieldInputContext.Provider>
);
};
/**
* React hook for accessing pre-wired services.
*
* @see {@link FieldInputServices}
*/
export const useServices = () => {
const context = useContext(FieldInputContext);
if (!context) {
throw new Error(
'FieldInputContext is missing. Ensure your component or React root is wrapped with FieldInputProvider.'
);
}
return context;
};

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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -0,0 +1,32 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/management-settings-types",
"@kbn/management-settings-field-definition",
"@kbn/monaco",
"@kbn/kibana-react-plugin",
"@kbn/management-settings-utilities",
"@kbn/i18n-react",
"@kbn/i18n",
"@kbn/core-notifications-browser",
"@kbn/core-ui-settings-common",
"@kbn/react-kibana-context-root",
"@kbn/core-theme-browser-mocks",
"@kbn/core-i18n-browser",
]
}

View file

@ -0,0 +1,64 @@
/*
* 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 } 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.
*/
export interface FieldInputServices {
/**
* Displays a danger toast message.
* @param value The message to display.
*/
showDanger: (value: string) => void;
}
/**
* An interface containing a collection of Kibana plugins and services required to
* render this component.
*/
export interface FieldInputKibanaDependencies {
/** The portion of the {@link ToastsStart} contract used by this component. */
toasts: Pick<ToastsStart, 'addDanger'>;
}
/**
* 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;
/** 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

@ -0,0 +1,37 @@
---
id: management/settings/components/fieldRow
slug: /management/settings/components/field-row
title: Management Settings Field Row Component
description: A package containing a component for rendering and manipulating a UiSetting in the Advanced Settings UI.
tags: ['management', 'settings']
date: 2023-08-31
---
## Description
This package contains a component for rendering and manipulating a single UiSetting in the Advanced Settings UI.
For reference, this is an example of the current Advanced Settings UI:
<div><img src="./assets/page.png" alt="Advanced Settings as a form." width="1000"/></div>
*Advanced Settings as a form.*
## Implementation
A `FormRow` represents a single UiSetting, and is responsible for rendering the UiSetting's label, description, and equivalent value input. It displays the state of any unsaved change, (e.g. error). It also handles the logic for updating the UiSetting's value in a consuming component through the `onChange` handler.
<div><img src="./assets/form_row.png" alt="Anatomy of a `FormRow`" width="1200"/></div>
*Anatomy of a `FormRow`*
## Notes
- This implementation was extracted from the `advancedSettings` plugin.
- The type for a `UiSettingMetadata` is limited due to the permissive nature of the [`UiSettingsParam` type](packages/core/ui-settings/core-ui-settings-common/src/ui_settings.ts).
- The source includes notations of several bugs which will surface if the assumptions about default settings from Kibana change.
## Testing
- Code coverage stands at 95%.
- Storybook stories are included. Run `yarn storybook management` to view them.

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.
*/
import { getFieldRowStory, getStory } from './common';
export default getStory('Array Row', 'A setting with an array of values.');
export const ArrayRow = getFieldRowStory('array' as const);

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.
*/
import { getStory, getFieldRowStory } from './common';
export default getStory('Boolean Row', 'A setting with a boolean value.');
export const BooleanRow = getFieldRowStory('boolean' as const);

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.
*/
import { getFieldRowStory, getStory } from './common';
export default getStory('Color Row', 'A setting with an base64 image value.');
export const ColorRow = getFieldRowStory('color' as const);

View file

@ -0,0 +1,150 @@
/*
* 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 type { ComponentMeta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { EuiPanel } from '@elastic/eui';
import { SettingType } 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 { FieldRow as Component, FieldRow } from '../field_row';
import { FieldRowProvider } from '../services';
import { OnChangeFn } from '../types';
/**
* Props for a {@link FieldInput} Storybook story.
*/
export interface StoryProps<T extends SettingType>
extends Pick<KnownTypeToMetadata<T>, 'userValue' | 'value'> {
/** Simulate if the UiSetting is custom. */
isCustom: boolean;
/** Simulate if the UiSetting is deprecated. */
isDeprecated: boolean;
/** Simulate if the UiSetting is overriden. */
isOverridden: boolean;
/** Simulate if saving settings is enabled in the UI. */
isSavingEnabled: boolean;
}
/**
* Utility function for returning a {@link FieldRow} Storybook story
* definition.
* @param title The title displayed in the Storybook UI.
* @param description The description of the Story.
* @returns A Storybook Story.
*/
export const getStory = (
title: string,
description: string,
argTypes: Record<string, unknown> = {}
) =>
({
title: `Settings/Field Row/${title}`,
description,
argTypes: {
userValue: {
name: 'Current saved value',
},
value: {
name: 'Default value from Kibana',
},
isSavingEnabled: {
name: 'Saving is enabled?',
},
isCustom: {
name: 'Setting is custom?',
},
isDeprecated: {
name: 'Setting is deprecated?',
},
isOverridden: {
name: 'Setting is overridden?',
},
...argTypes,
},
decorators: [
(Story) => (
<FieldRowProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
>
<EuiPanel>
<Story />
</EuiPanel>
</FieldRowProvider>
),
],
} as ComponentMeta<typeof Component>);
/**
* Default argument values for a {@link FieldInput} Storybook story.
*/
export const storyArgs = {
/** True if the saving settings is disabled, false otherwise. */
isSavingEnabled: true,
/** True if the UiSetting is custom, false otherwise. */
isCustom: false,
/** True if the UiSetting is deprecated, false otherwise. */
isDeprecated: false,
/** True if the UiSetting is overridden, false otherwise. */
isOverridden: false,
};
/**
* Utility function for returning a {@link FieldRow} Storybook story.
* @param type The type of the UiSetting for this {@link FieldRow}.
* @returns A Storybook Story.
*/
export const getFieldRowStory = (
type: SettingType,
settingFields: Partial<UiSettingMetadata<SettingType>>
) => {
const Story = ({
isCustom,
isDeprecated,
isOverridden,
isSavingEnabled,
userValue,
value,
}: StoryProps<typeof type>) => {
const setting: UiSettingMetadata<typeof type> = {
type,
value,
userValue,
name: `Some ${type} setting`,
...settingFields,
};
const [field, unsavedChange, onChangeFn] = useFieldDefinition(setting, {
isCustom,
isDeprecated,
isOverridden,
});
const onChange: OnChangeFn<typeof type> = (_key, change) => {
const { error, isInvalid, unsavedValue } = change;
onChangeFn({ error: error === null ? undefined : error, isInvalid, value: unsavedValue });
};
return <FieldRow {...{ field, unsavedChange, isSavingEnabled, onChange }} />;
};
Story.args = {
userValue: getUserValue(type),
value: getDefaultValue(type),
...storyArgs,
};
return Story;
};

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.
*/
import { getFieldRowStory, getStory } from './common';
export default getStory('Image Row', 'A setting with an base64 image value.');
export const ImageRow = getFieldRowStory('image' as const);

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.
*/
import { getFieldRowStory, getStory } from './common';
export default getStory('JSON Row', 'A setting with a JSON value.');
export const JSONRow = getFieldRowStory('json' as const);

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.
*/
import { getFieldRowStory, getStory } from './common';
export default getStory('Markdown Row', 'A setting with a Markdown value.');
export const MarkdownRow = getFieldRowStory('markdown' as const);

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.
*/
import { getFieldRowStory, getStory } from './common';
export default getStory('Number Row', 'A setting with a numeric value.');
export const NumberRow = getFieldRowStory('number' as const);

View file

@ -0,0 +1,27 @@
/*
* 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 { getFieldRowStory, getStory } from './common';
const argTypes = {
value: {
name: 'Current saved value',
control: {
type: 'select',
options: ['option1', 'option2', 'option3'],
},
},
};
const settingFields = {
optionLabels: { option1: 'Option 1', option2: 'Option 2', option3: 'Option 3' },
options: ['option1', 'option2', 'option3'],
};
export default getStory('Select Row', 'A setting with a boolean value.', argTypes);
export const SelectRow = getFieldRowStory('select' as const, settingFields);

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.
*/
import { getFieldRowStory, getStory } from './common';
export default getStory('String Row', 'A setting with a string value.');
export const StringRow = getFieldRowStory('string' as const);

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

View file

@ -0,0 +1,84 @@
/*
* 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 { FieldDefaultValue, DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX } from './default_value';
import { wrap } from '../mocks';
describe('FieldDefaultValue', () => {
it('renders without errors', () => {
const { container } = render(
wrap(
<FieldDefaultValue
field={{
id: 'test',
type: 'string',
isDefaultValue: false,
defaultValueDisplay: 'null',
}}
/>
)
);
expect(container).toBeInTheDocument();
});
it('renders nothing if the default value is set', () => {
const { container } = render(
wrap(
<FieldDefaultValue
field={{
id: 'test',
type: 'string',
isDefaultValue: true,
defaultValueDisplay: 'null',
}}
/>
)
);
expect(container).toBeEmptyDOMElement();
});
it('does not render a code block for string fields', () => {
const { queryByTestId, getByText } = render(
wrap(
<FieldDefaultValue
field={{
id: 'test',
type: 'string',
isDefaultValue: false,
defaultValueDisplay: 'hello world',
}}
/>
)
);
const input = queryByTestId(`${DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX}-test`);
expect(input).not.toBeInTheDocument();
const label = getByText('hello world');
expect(label).toBeInTheDocument();
});
it('renders a code block for JSON fields', () => {
const { getByTestId } = render(
wrap(
<FieldDefaultValue
field={{
id: 'test',
type: 'json',
isDefaultValue: false,
defaultValueDisplay: '{ foo: bar }',
}}
/>
)
);
const input = getByTestId(`${DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX}-test`);
expect(input).toBeInTheDocument();
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { EuiCode, EuiCodeBlock, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
isJsonFieldDefinition,
isMarkdownFieldDefinition,
} from '@kbn/management-settings-field-definition';
import { FieldDefinition, SettingType } from '@kbn/management-settings-types';
export const DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX = 'default-display-block';
/**
* Props for a {@link FieldDefaultValue} component.
*/
export interface FieldDefaultValueProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<FieldDefinition<T>, 'id' | 'type' | 'isDefaultValue' | 'defaultValueDisplay'>;
}
/**
* Component for displaying the default value of a {@link FieldDefinition}
* in the {@link FieldRow}.
*/
export const FieldDefaultValue = <T extends SettingType>({ field }: FieldDefaultValueProps<T>) => {
if (field.isDefaultValue) {
return null;
}
const { defaultValueDisplay: display, id } = field;
let value = <EuiCode>{display}</EuiCode>;
if (isJsonFieldDefinition(field) || isMarkdownFieldDefinition(field)) {
value = (
<EuiCodeBlock
data-test-subj={`${DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX}-${id}`}
language={field.type}
paddingSize="s"
overflowHeight={display.length >= 500 ? 300 : undefined}
>
{display}
</EuiCodeBlock>
);
}
return (
<EuiText size="xs">
<FormattedMessage
id="management.settings.defaultValueText"
defaultMessage="Default: {value}"
values={{
value,
}}
/>
</EuiText>
);
};

View file

@ -0,0 +1,58 @@
/*
* 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 { FieldDeprecation } from './deprecation';
import { wrap } from '../mocks';
describe('FieldDeprecation', () => {
const defaultProps = {
field: {
name: 'test',
type: 'string',
deprecation: undefined,
},
};
it('renders without errors', () => {
const { container } = render(
wrap(
<FieldDeprecation
{...defaultProps}
field={{
...defaultProps.field,
deprecation: { message: 'Test message', docLinksKey: 'deprecationKey' },
}}
/>
)
);
expect(container).toBeInTheDocument();
});
it('renders nothing if there is no deprecation', () => {
const { container } = render(wrap(<FieldDeprecation {...defaultProps} />));
expect(container.firstChild).toBeNull();
});
it('renders a warning badge if there is a deprecation', () => {
const { getByText } = render(
wrap(
<FieldDeprecation
{...defaultProps}
field={{
...defaultProps.field,
deprecation: { message: 'Test message', docLinksKey: 'deprecationKey' },
}}
/>
)
);
const badge = getByText('Deprecated');
expect(badge).toBeInTheDocument();
});
});

View file

@ -0,0 +1,66 @@
/*
* 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.
*/
/*
* 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';
import { i18n } from '@kbn/i18n';
import { FieldDefinition, SettingType } from '@kbn/management-settings-types';
import { useServices } from '../services';
export const DATA_TEST_SUBJ_DEPRECATION_PREFIX = 'description-block-deprecation';
type Field<T extends SettingType> = Pick<FieldDefinition<T>, 'id' | 'deprecation' | 'name'>;
/**
* Props for a {@link FieldDeprecation} component.
*/
export interface FieldDeprecationProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Field<T>;
}
/**
*
*/
export const FieldDeprecation = <T extends SettingType>({ field }: FieldDeprecationProps<T>) => {
const { links } = useServices();
const { deprecation, name, id } = field;
if (!deprecation) {
return null;
}
return (
<div data-test-subj={`${DATA_TEST_SUBJ_DEPRECATION_PREFIX}-${id}`}>
<EuiToolTip content={deprecation.message}>
<EuiBadge
color="warning"
onClick={() => {
window.open(links[deprecation!.docLinksKey], '_blank');
}}
onClickAriaLabel={i18n.translate('management.settings.field.deprecationClickAreaLabel', {
defaultMessage: 'Click to view deprecation documentation for {name}.',
values: {
name,
},
})}
>
Deprecated
</EuiBadge>
</EuiToolTip>
</div>
);
};

View file

@ -0,0 +1,43 @@
/*
* 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 { FieldDescription } from './description';
import { FieldDefinition } from '@kbn/management-settings-types';
import { wrap } from '../mocks';
const description = 'hello world description';
describe('FieldDescription', () => {
const defaultProps = {
field: {
defaultValue: null,
defaultValueDisplay: 'null',
id: 'test',
isDefaultValue: false,
name: 'test',
savedValue: 'hello world',
type: 'string',
} as FieldDefinition<'string'>,
};
it('renders without errors', () => {
const { getByText } = render(
wrap(
<FieldDescription {...{ ...defaultProps, field: { ...defaultProps.field, description } }} />
)
);
expect(getByText(description)).toBeInTheDocument();
});
it('renders no description without one', () => {
const { queryByText } = render(wrap(<FieldDescription {...defaultProps} />));
expect(queryByText(description)).toBeNull();
});
});

View file

@ -0,0 +1,81 @@
/*
* 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, { ReactElement } from 'react';
import { FieldDefinition, SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
import { EuiText } from '@elastic/eui';
import { useFieldStyles } from '../field_row.styles';
import { FieldDeprecation } from './deprecation';
import { FieldDefaultValue } from './default_value';
export const DATA_TEST_SUBJ_DESCRIPTION = 'settings-description';
type Field<T extends SettingType> = Pick<
FieldDefinition<T>,
| 'defaultValue'
| 'defaultValueDisplay'
| 'description'
| 'id'
| 'isDefaultValue'
| 'name'
| 'savedValue'
| 'type'
>;
/**
* Props for a {@link FieldDescription} component.
*/
export interface FieldDescriptionProps<T extends SettingType> {
field: Field<T>;
unsavedChange?: UnsavedFieldChange<T>;
}
/**
* Component for displaying the description of a {@link FieldDefinition}.
*/
export const FieldDescription = <T extends SettingType>({
field,
unsavedChange,
}: FieldDescriptionProps<T>) => {
const { cssDescription } = useFieldStyles({ field, unsavedChange });
const { description, name } = field;
// TODO - this does *not* match the `UiSetting` type.
// @see packages/core/ui-settings/core-ui-settings-common/src/ui_settings.ts
let content: ReactElement | string | undefined = description;
if (!React.isValidElement(content)) {
content = (
<div
/*
* Justification for dangerouslySetInnerHTML:
* Setting description may contain formatting and links to documentation.
*/
dangerouslySetInnerHTML={{ __html: content || '' }} // eslint-disable-line react/no-danger
/>
);
}
if (content) {
content = (
<EuiText size="s" data-test-subj={`${DATA_TEST_SUBJ_DESCRIPTION}-${name}`}>
{content}
</EuiText>
);
}
return (
<div css={cssDescription}>
<FieldDeprecation {...{ field }} />
{content}
<FieldDefaultValue {...{ field }} />
</div>
);
};

View file

@ -0,0 +1,9 @@
/*
* 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 { FieldDescription, type FieldDescriptionProps } from './description';

View file

@ -0,0 +1,56 @@
/*
* 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';
import { UnsavedFieldChange, FieldDefinition, SettingType } from '@kbn/management-settings-types';
import { hasUnsavedChange } from '@kbn/management-settings-utilities';
/**
* Parameters for the {@link useFieldStyles} hook.
*/
export interface Params<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<FieldDefinition<T>, 'savedValue'>;
/** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */
unsavedChange?: UnsavedFieldChange<T>;
}
/**
* A React hook that provides stateful `css` classes for the {@link FieldRow} component.
*/
export const useFieldStyles = <T extends SettingType>({ field, unsavedChange }: Params<T>) => {
const {
euiTheme: { size, colors },
} = useEuiTheme();
const unsaved = hasUnsavedChange(field, unsavedChange);
const error = unsavedChange?.error;
return {
cssFieldFormGroup: css`
+ * {
margin-top: ${size.base};
}
`,
cssFieldTitle: css`
font-weight: bold;
padding-left: ${size.s};
margin-left: -${size.s};
${unsaved ? `box-shadow: -${size.xs} 0 ${colors.warning};` : ''}
${error ? `box-shadow: -${size.xs} 0 ${colors.danger};` : ''}
`,
cssDescription: css`
& > div {
margin-bottom: ${size.s};
}
`,
};
};

View file

@ -0,0 +1,481 @@
/*
* 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 { fireEvent, render, waitFor } from '@testing-library/react';
import { SettingType } from '@kbn/management-settings-types';
import { getFieldDefinition } from '@kbn/management-settings-field-definition';
import { KnownTypeToMetadata } from '@kbn/management-settings-types/metadata';
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';
const defaults = {
requiresPageReload: false,
readonly: false,
category: ['category'],
};
const defaultValues: Record<SettingType, any> = {
array: ['example_value'],
boolean: true,
color: '#FF00CC',
image: '',
json: "{ foo: 'bar2' }",
markdown: 'Hello World',
number: 1,
select: 'apple',
string: 'hello world',
undefined: 'undefined',
};
const defaultInputValues: Record<SettingType, any> = {
array: 'example_value',
boolean: true,
color: '#FF00CC',
image: '',
json: '{"hello": "world"}',
markdown: '**bold**',
number: 1,
select: 'apple',
string: 'hello world',
undefined: 'undefined',
};
const userValues: Record<SettingType, any> = {
array: ['user', 'value'],
boolean: false,
image: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
json: '{"hello": "world"}',
markdown: '**bold**',
number: 10,
select: 'banana',
string: 'foo',
color: '#FACF0C',
undefined: 'something',
};
const userInputValues: Record<SettingType, any> = {
array: 'user, value',
boolean: false,
image: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
json: '{"hello": "world"}',
markdown: '**bold**',
number: 10,
select: 'banana',
string: 'foo',
color: '#FACF0C',
undefined: 'something',
};
type Settings = {
[key in SettingType]: KnownTypeToMetadata<key>;
};
const settings: Omit<Settings, 'markdown' | 'json'> = {
array: {
description: 'Description for Array test setting',
name: 'array:test:setting',
type: 'array',
userValue: undefined,
value: defaultValues.array,
...defaults,
},
boolean: {
description: 'Description for Boolean test setting',
name: 'boolean:test:setting',
type: 'boolean',
userValue: undefined,
value: defaultValues.boolean,
...defaults,
},
color: {
description: 'Description for Color test setting',
name: 'color:test:setting',
type: 'color',
userValue: undefined,
value: defaultValues.color,
...defaults,
},
image: {
description: 'Description for Image test setting',
name: 'image:test:setting',
type: 'image',
userValue: undefined,
value: defaultValues.image,
...defaults,
},
// This is going to take a lot of mocks to test.
//
// json: {
// name: 'json:test:setting',
// description: 'Description for Json test setting',
// type: 'json',
// userValue: '{"foo": "bar"}',
// value: '{}',
// ...defaults,
// },
//
// This is going to take a lot of mocks to test.
//
// markdown: {
// name: 'markdown:test:setting',
// description: 'Description for Markdown test setting',
// type: 'markdown',
// userValue: undefined,
// value: '',
// ...defaults,
// },
number: {
description: 'Description for Number test setting',
name: 'number:test:setting',
type: 'number',
userValue: undefined,
value: defaultValues.number,
...defaults,
},
select: {
description: 'Description for Select test setting',
name: 'select:test:setting',
options: ['apple', 'orange', 'banana'],
optionLabels: {
apple: 'Apple',
orange: 'Orange',
banana: 'Banana',
},
type: 'select',
userValue: undefined,
value: defaultValues.select,
...defaults,
},
string: {
description: 'Description for String test setting',
name: 'string:test:setting',
type: 'string',
userValue: undefined,
value: defaultValues.string,
...defaults,
},
undefined: {
description: 'Description for Undefined test setting',
name: 'undefined:test:setting',
type: 'undefined',
userValue: undefined,
value: defaultValues.undefined,
...defaults,
},
};
const handleChange = jest.fn();
describe('Field', () => {
beforeEach(() => {
jest.clearAllMocks();
});
(Object.keys(settings) as SettingType[]).forEach((type) => {
if (type === 'json' || type === 'markdown') {
return;
}
const setting = settings[type];
const id = settings[type].name || type;
const inputTestSubj = `${TEST_SUBJ_PREFIX_FIELD}-${id}`;
describe(`for ${type} setting`, () => {
it('should render', () => {
const { container } = render(
wrap(
<FieldRow
field={getFieldDefinition({ id, setting })}
onChange={handleChange}
isSavingEnabled={true}
/>
)
);
expect(container).toBeInTheDocument();
});
it('should render default value if there is no user value set', () => {
const { getByTestId } = render(
wrap(
<FieldRow
field={getFieldDefinition({ id, setting })}
onChange={handleChange}
isSavingEnabled={true}
/>
)
);
if (type === 'boolean') {
expect(getByTestId(inputTestSubj)).toHaveAttribute('aria-checked', 'true');
} else if (type === 'color') {
expect(getByTestId(`euiColorPickerAnchor ${inputTestSubj}`)).toHaveValue(
defaultInputValues[type]
);
} else if (type === 'number') {
expect(getByTestId(inputTestSubj)).toHaveValue(defaultInputValues[type]);
} else if (type === 'image') {
expect(getByTestId(inputTestSubj)).toBeInTheDocument();
expect(getByTestId(inputTestSubj)).toHaveAttribute('type', 'file');
} else {
expect(getByTestId(inputTestSubj)).toHaveValue(String(defaultInputValues[type]) as any);
}
});
it('should render as read only with help text if overridden', async () => {
const { getByTestId } = render(
wrap(
<FieldRow
field={getFieldDefinition({
id,
setting,
params: { isOverridden: true },
})}
onChange={handleChange}
isSavingEnabled={true}
/>
)
);
if (type === 'color') {
expect(getByTestId(`euiColorPickerAnchor ${inputTestSubj}`)).toBeDisabled();
} else {
expect(getByTestId(inputTestSubj)).toBeDisabled();
}
expect(getByTestId(`${DATA_TEST_SUBJ_OVERRIDDEN_PREFIX}-${id}`)).toBeInTheDocument();
});
it('should render as read only if saving is disabled', () => {
const { getByTestId } = render(
wrap(
<FieldRow
field={getFieldDefinition({
id,
setting,
})}
onChange={handleChange}
isSavingEnabled={false}
/>
)
);
if (type === 'color') {
expect(getByTestId(`euiColorPickerAnchor ${inputTestSubj}`)).toBeDisabled();
} else {
expect(getByTestId(inputTestSubj)).toBeDisabled();
}
});
it('should render user value if there is user value is set', async () => {
const { getByTestId, getByAltText } = render(
wrap(
<FieldRow
field={getFieldDefinition({
id,
setting: {
...setting,
userValue: userValues[type] as any,
},
})}
onChange={handleChange}
isSavingEnabled={true}
/>
)
);
if (type === 'boolean') {
expect(getByTestId(inputTestSubj)).toHaveAttribute('aria-checked', 'false');
} else if (type === 'color') {
expect(getByTestId(`euiColorPickerAnchor ${inputTestSubj}`)).toHaveValue(
userValues[type]
);
} else if (type === 'number') {
expect(getByTestId(inputTestSubj)).toHaveValue(userValues[type]);
} else if (type === 'image') {
expect(getByAltText(id)).toBeInTheDocument();
expect(getByAltText(id)).toHaveAttribute('src', userValues[type]);
} else {
expect(getByTestId(inputTestSubj)).toHaveValue(String(userInputValues[type]) as any);
}
});
it('should render custom setting icon if it is custom', () => {
const { getByText } = render(
wrap(
<FieldRow
field={getFieldDefinition({
id,
setting,
params: { isCustom: true },
})}
onChange={handleChange}
isSavingEnabled={true}
/>
)
);
expect(getByText('Custom setting')).toBeInTheDocument();
});
it('should render unsaved value if there are unsaved changes', () => {
const { getByTestId, getByAltText } = render(
wrap(
<FieldRow
field={getFieldDefinition({
id,
setting: { ...setting, userValue: userValues[type] as any },
params: { isCustom: true },
})}
unsavedChange={{
type,
unsavedValue: userValues[type] as any,
}}
onChange={handleChange}
isSavingEnabled={true}
/>
)
);
if (type === 'boolean') {
expect(getByTestId(inputTestSubj)).toHaveAttribute('aria-checked', 'false');
} else if (type === 'color') {
expect(getByTestId(`euiColorPickerAnchor ${inputTestSubj}`)).toHaveValue(
userInputValues[type]
);
} else if (type === 'number') {
expect(getByTestId(inputTestSubj)).toHaveValue(userInputValues[type]);
} else if (type === 'image') {
expect(getByAltText(id)).toBeInTheDocument();
expect(getByAltText(id)).toHaveAttribute('src', userValues[type]);
} else {
expect(getByTestId(inputTestSubj)).toHaveValue(String(userInputValues[type]) as any);
}
});
it('should reset when reset link is clicked', () => {
const field = getFieldDefinition({
id,
setting: {
...setting,
userValue: userValues[type],
},
});
const { getByTestId } = render(
wrap(<FieldRow field={field} onChange={handleChange} isSavingEnabled={true} />)
);
const input = getByTestId(`${DATA_TEST_SUBJ_RESET_PREFIX}-${field.id}`);
fireEvent.click(input);
expect(handleChange).toHaveBeenCalledWith(field.id, {
type,
unsavedValue: field.defaultValue,
});
});
});
});
it('should fire onChange when input changes', () => {
const setting = settings.string;
const field = getFieldDefinition({ id: setting.name || setting.type, setting });
const { getByTestId } = render(
wrap(<FieldRow field={field} onChange={handleChange} isSavingEnabled={true} />)
);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${field.id}`);
fireEvent.change(input, { target: { value: 'new value' } });
expect(handleChange).toHaveBeenCalledWith(field.id, {
type: 'string',
unsavedValue: 'new value',
});
});
it('should fire onChange with an error when input changes with invalid value', () => {
const setting = settings.color;
const field = getFieldDefinition({ id: setting.name || setting.type, setting });
const { getByTestId } = render(
wrap(<FieldRow field={field} onChange={handleChange} isSavingEnabled={true} />)
);
const input = getByTestId(`euiColorPickerAnchor ${TEST_SUBJ_PREFIX_FIELD}-${field.id}`);
fireEvent.change(input, { target: { value: '#1234' } });
expect(handleChange).toHaveBeenCalledWith(field.id, {
type: 'color',
error: 'Provide a valid color value',
isInvalid: true,
unsavedValue: '#1234',
});
});
it('should show screen reader content with an unsaved change.', () => {
const setting = settings.color;
const field = getFieldDefinition({ id: setting.name || setting.type, setting });
const { getByText, getByTestId } = render(
wrap(
<FieldRow
field={field}
onChange={handleChange}
isSavingEnabled={true}
unsavedChange={{
type: setting.type,
unsavedValue: '#123456',
}}
/>
)
);
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'
)
);
});
it('should clear the unsaved value if the new value matches the saved value', () => {
const setting = settings.string;
const field = getFieldDefinition({
id: setting.name || setting.type,
setting: {
...setting,
userValue: 'saved value',
},
});
const unsavedChange = {
type: 'string' as const,
unsavedValue: 'new value',
};
const { getByTestId } = render(
wrap(
<FieldRow {...{ field, unsavedChange }} onChange={handleChange} isSavingEnabled={true} />
)
);
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,
});
});
});

View file

@ -0,0 +1,160 @@
/*
* 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 {
EuiScreenReaderOnly,
EuiDescribedFormGroup,
EuiFormRow,
EuiErrorBoundary,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type {
FieldDefinition,
SettingType,
UnsavedFieldChange,
} 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 { FieldDescription } from './description';
import { FieldTitle } from './title';
import { FieldInputFooter } from './input_footer';
import { useFieldStyles } from './field_row.styles';
import { OnChangeFn } from './types';
export const DATA_TEST_SUBJ_SCREEN_READER_MESSAGE = 'fieldRowScreenReaderMessage';
/**
* Props for a {@link FieldRow} component.
*/
export interface FieldRowProps<T extends SettingType> {
/** True if saving settings is enabled, false otherwise. */
isSavingEnabled: boolean;
/** The {@link OnChangeFn} handler. */
onChange: OnChangeFn<T>;
/**
* 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>;
}
/**
* 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>) => {
const { isSavingEnabled, onChange: onChangeProp, field, unsavedChange } = props;
const { id, name, groupId, isOverridden, type, unsavedFieldId } = field;
const { cssFieldFormGroup } = useFieldStyles({
field,
unsavedChange,
});
const onChange = (changes: UnsavedFieldChange<T>) => {
onChangeProp(name, changes);
};
const resetField = () => {
const { defaultValue: unsavedValue } = field;
return onChange({ type, unsavedValue });
};
const onFieldChange = ({ isInvalid, error, value: unsavedValue }: OnChangeParams<T>) => {
if (error) {
isInvalid = true;
}
const change = {
type,
isInvalid,
error,
};
if (!isUnsavedValue(field, unsavedValue)) {
onChange(change);
} else {
onChange({
...change,
unsavedValue,
});
}
};
const title = <FieldTitle {...{ field, unsavedChange }} />;
const description = <FieldDescription {...{ field }} />;
const error = unsavedChange?.error;
const isInvalid = unsavedChange?.isInvalid;
let unsavedScreenReaderMessage = null;
const helpText = (
<FieldInputFooter
{...{
field,
unsavedChange,
isSavingEnabled,
onCancel: resetField,
onReset: resetField,
onChange: onFieldChange,
}}
/>
);
if (unsavedChange) {
unsavedScreenReaderMessage = (
<EuiScreenReaderOnly>
<p
id={`${unsavedFieldId}`}
data-test-subj={`${DATA_TEST_SUBJ_SCREEN_READER_MESSAGE}-${id}`}
>
{error
? error
: i18n.translate('management.settings.field.settingIsUnsaved', {
defaultMessage: 'Setting is currently not saved.',
})}
</p>
</EuiScreenReaderOnly>
);
}
return (
<EuiErrorBoundary>
<EuiDescribedFormGroup
id={groupId}
fullWidth
css={cssFieldFormGroup}
{...{ title, description }}
>
<EuiFormRow
fullWidth
hasChildLabel={!isImageFieldDefinition(field)}
label={id}
{...{ isInvalid, error, helpText }}
>
<>
<FieldInput
isDisabled={!isSavingEnabled || isOverridden}
isInvalid={unsavedChange?.isInvalid}
onChange={onFieldChange}
{...{ field, unsavedChange }}
/>
{unsavedScreenReaderMessage}
</>
</EuiFormRow>
</EuiDescribedFormGroup>
</EuiErrorBoundary>
);
};

View file

@ -0,0 +1,9 @@
/*
* 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 { FieldRow, type FieldRowProps as FieldProps } from './field_row';

View file

@ -0,0 +1,81 @@
/*
* 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

@ -0,0 +1,86 @@
/*
* 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, 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';
type Field<T extends SettingType> = Pick<
FieldDefinition<T>,
'name' | 'defaultValue' | 'type' | 'savedValue' | 'savedValue' | 'ariaAttributes'
>;
/**
* 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>;
}
/**
* Component for rendering a link to change the image in a {@link FieldRow} of
* an {@link ImageFieldDefinition}.
*/
export const ChangeImageLink = <T extends SettingType>({
field,
onChange,
unsavedChange,
}: ChangeImageLinkProps<T>) => {
if (hasUnsavedChange(field, unsavedChange)) {
return null;
}
const { unsavedValue } = unsavedChange || {};
const {
savedValue,
ariaAttributes: { ariaLabel },
name,
defaultValue,
} = field;
if (unsavedValue || !savedValue) {
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;
};

View file

@ -0,0 +1,9 @@
/*
* 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 { FieldInputFooter, type FieldInputFooterProps } from './input_footer';

View file

@ -0,0 +1,66 @@
/*
* 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 type {
FieldDefinition,
SettingType,
UnsavedFieldChange,
} from '@kbn/management-settings-types';
import { OnChangeFn } from '@kbn/management-settings-components-field-input';
import { FieldResetLink } from './reset_link';
import { ChangeImageLink } from './change_image_link';
import { FieldOverriddenMessage } from './overridden_message';
export const DATA_TEST_SUBJ_FOOTER_PREFIX = 'field-row-input-footer';
type Field<T extends SettingType> = Pick<
FieldDefinition<T>,
'id' | 'name' | 'isOverridden' | 'type' | 'ariaAttributes' | 'isDefaultValue'
>;
/**
* Props for a {@link FieldInputFooter} component.
*/
export interface FieldInputFooterProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
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 when a field is reset to its default or saved value. */
onReset: () => void;
/** True if saving this setting is enabled, false otherwise. */
isSavingEnabled: boolean;
}
export const FieldInputFooter = <T extends SettingType>({
isSavingEnabled,
field,
onReset,
...props
}: FieldInputFooterProps<T>) => {
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>
);
}
return null;
};

View file

@ -0,0 +1,34 @@
/*
* 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 { FieldOverriddenMessage } from './overridden_message';
import { FieldDefinition } from '@kbn/management-settings-types';
describe('FieldOverriddenMessage', () => {
const defaultProps = {
field: {
name: 'test',
type: 'string',
isOverridden: false,
} as FieldDefinition<'string'>,
};
it('renders without errors', () => {
const { container } = render(
<FieldOverriddenMessage {...{ ...defaultProps, isOverridden: true }} />
);
expect(container).toBeInTheDocument();
});
it('renders nothing if the field is not overridden', () => {
const { container } = render(<FieldOverriddenMessage {...defaultProps} />);
expect(container.firstChild).toBeNull();
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FieldDefinition, SettingType } from '@kbn/management-settings-types';
type Field<T extends SettingType> = Pick<FieldDefinition<T>, 'id' | 'isOverridden' | 'name'>;
export const DATA_TEST_SUBJ_OVERRIDDEN_PREFIX = 'field-row-input-overridden-message';
/**
* Props for a {@link FieldOverriddenMessage} component.
*/
export interface FieldOverriddenMessageProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Field<T>;
}
export const FieldOverriddenMessage = <T extends SettingType>({
field,
}: FieldOverriddenMessageProps<T>) => {
if (!field.isOverridden) {
return null;
}
return (
<EuiText size="xs" data-test-subj={`${DATA_TEST_SUBJ_OVERRIDDEN_PREFIX}-${field.id}`}>
<FormattedMessage
id="management.settings.helpText"
defaultMessage="This setting is overridden by the Kibana server and can not be changed."
/>
</EuiText>
);
};

View file

@ -0,0 +1,54 @@
/*
* 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

@ -0,0 +1,64 @@
/*
* 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

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

View file

@ -0,0 +1,54 @@
/*
* 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, { ReactChild } from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaRootContextProvider } from '@kbn/react-kibana-context-root';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { I18nStart } from '@kbn/core-i18n-browser';
import { createFieldInputServicesMock } from '@kbn/management-settings-components-field-input/mocks';
import { FieldInputServices } from '@kbn/management-settings-components-field-input/mocks';
import { FieldRowProvider } from '../services';
import { FieldRowServices } from '../types';
const createRootMock = () => {
const i18n: I18nStart = {
Context: ({ children }) => <I18nProvider>{children}</I18nProvider>,
};
const theme = themeServiceMock.createStartContract();
return {
i18n,
theme,
};
};
export const createFieldRowServicesMock = (): FieldRowServices => ({
...createFieldInputServicesMock(),
links: { deprecationKey: 'link/to/deprecation/docs' },
});
export const TestWrapper = ({
children,
services = createFieldRowServicesMock(),
}: {
children: ReactChild;
services?: FieldRowServices;
}) => {
return (
<KibanaRootContextProvider {...createRootMock()}>
<FieldRowProvider {...services}>{children}</FieldRowProvider>
</KibanaRootContextProvider>
);
};
export const wrap = (
component: JSX.Element,
services: FieldInputServices = createFieldRowServicesMock()
) => <TestWrapper {...services}>{component}</TestWrapper>;

View file

@ -0,0 +1,9 @@
/*
* 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 { TestWrapper, createFieldRowServicesMock, wrap } from './context';

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/management-settings-components-field-row",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,66 @@
/*
* 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 {
FieldInputKibanaProvider,
FieldInputProvider,
} from '@kbn/management-settings-components-field-input/services';
import React, { FC, useContext } from 'react';
import type { FieldRowServices, FieldRowKibanaDependencies, Services } from './types';
const FieldRowContext = React.createContext<Services | null>(null);
/**
* React Provider that provides services to a {@link FieldRow} component and its dependents.
*/
export const FieldRowProvider: FC<FieldRowServices> = ({ children, ...services }) => {
// 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;
return (
<FieldRowContext.Provider value={{ links }}>
<FieldInputProvider {...{ showDanger }}>{children}</FieldInputProvider>
</FieldRowContext.Provider>
);
};
/**
* Kibana-specific Provider that maps Kibana plugins and services to a {@link FieldRowProvider}.
*/
export const FieldRowKibanaProvider: FC<FieldRowKibanaDependencies> = ({
children,
docLinks,
toasts,
}) => {
return (
<FieldRowContext.Provider
value={{
links: docLinks.links.management,
}}
>
<FieldInputKibanaProvider {...{ toasts }}>{children}</FieldInputKibanaProvider>
</FieldRowContext.Provider>
);
};
/**
* React hook for accessing pre-wired services.
*/
export const useServices = () => {
const context = useContext(FieldRowContext);
if (!context) {
throw new Error(
'FieldRowContext is missing. Ensure your component or React root is wrapped with FieldRowProvider.'
);
}
return context;
};

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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -0,0 +1,48 @@
/*
* 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 { EuiIconTip } 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 FieldTitle} component.
*/
export interface TitleProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<FieldDefinition<T>, 'isCustom'>;
}
/**
*
*/
export const FieldTitleCustomIcon = <T extends SettingType>({ field }: TitleProps<T>) => {
if (!field.isCustom) {
return null;
}
return (
<EuiIconTip
type="asterisk"
color="primary"
aria-label={i18n.translate('management.settings.field.customSettingAriaLabel', {
defaultMessage: 'Custom setting',
})}
content={
<FormattedMessage
id="management.settings.customSettingTooltip"
defaultMessage="Custom setting"
/>
}
/>
);
};

View file

@ -0,0 +1,58 @@
/*
* 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 { EuiIconTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldDefinition, UnsavedFieldChange, SettingType } from '@kbn/management-settings-types';
import { hasUnsavedChange } from '@kbn/management-settings-utilities';
/**
* Props for a {@link FieldTitle} component.
*/
export interface TitleProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<FieldDefinition<T>, 'id' | 'type' | 'isOverridden' | 'savedValue'>;
/** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */
unsavedChange?: UnsavedFieldChange<T>;
}
/**
*
*/
export const FieldTitleUnsavedIcon = <T extends SettingType>({
field,
unsavedChange,
}: TitleProps<T>) => {
if (!unsavedChange || !hasUnsavedChange(field, unsavedChange)) {
return null;
}
const { isInvalid } = unsavedChange;
const invalidLabel = i18n.translate('management.settings.field.invalidIconLabel', {
defaultMessage: 'Invalid',
});
const unsavedLabel = i18n.translate('management.settings.field.unsavedIconLabel', {
defaultMessage: 'Unsaved',
});
const unsavedIconLabel = unsavedChange.isInvalid ? invalidLabel : unsavedLabel;
return (
<EuiIconTip
type={isInvalid ? 'warning' : 'dot'}
color={isInvalid ? 'danger' : 'warning'}
aria-label={unsavedIconLabel}
content={unsavedIconLabel}
/>
);
};

View file

@ -0,0 +1,9 @@
/*
* 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 { FieldTitle, type TitleProps } from './title';

View file

@ -0,0 +1,61 @@
/*
* 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 type { Interpolation, Theme } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FieldDefinition, UnsavedFieldChange, SettingType } from '@kbn/management-settings-types';
import { useFieldStyles } from '../field_row.styles';
import { FieldTitleCustomIcon } from './icon_custom';
import { FieldTitleUnsavedIcon } from './icon_unsaved';
/**
* Props for a {@link FieldTitle} component.
*/
export interface TitleProps<T extends SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: FieldDefinition<T>;
/** Emotion-based `css` for the root React element. */
css?: Interpolation<Theme>;
/** Classname for the root React element. */
className?: string;
/** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */
unsavedChange?: UnsavedFieldChange<T>;
}
/**
* Component for displaying the `displayName` and status of a {@link FieldDefinition} in
* the {@link FieldRow}.
*/
export const FieldTitle = <T extends SettingType>({
field,
unsavedChange,
...props
}: TitleProps<T>) => {
const { cssFieldTitle } = useFieldStyles({
field,
unsavedChange,
});
return (
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false} css={cssFieldTitle}>
<h3 {...props}>{field.displayName}</h3>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FieldTitleCustomIcon {...{ field }} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FieldTitleUnsavedIcon {...{ field, unsavedChange }} />
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,33 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@kbn/ambient-ui-types",
"@kbn/ambient-storybook-types",
"@emotion/react/types/css-prop"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/management-settings-types",
"@kbn/management-settings-field-definition",
"@kbn/i18n",
"@kbn/i18n-react",
"@kbn/management-settings-utilities",
"@kbn/management-settings-components-field-input",
"@kbn/core-doc-links-browser",
"@kbn/react-kibana-context-root",
"@kbn/core-theme-browser-mocks",
"@kbn/core-i18n-browser",
]
}

View file

@ -0,0 +1,52 @@
/*
* 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 { DocLinksStart } from '@kbn/core-doc-links-browser';
import type {
FieldInputServices,
FieldInputKibanaDependencies,
} from '@kbn/management-settings-components-field-input';
import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
/**
* Contextual services used by a {@link FieldRow} component.
*/
export interface Services {
links: { [key: string]: string };
}
/**
* Contextual services used by a {@link FieldRow} component and its dependents.
*/
export type FieldRowServices = FieldInputServices & Services;
/**
* An interface containing a collection of Kibana plugins and services required to
* render a {@link FieldRow} component.
*/
export interface KibanaDependencies {
docLinks: {
links: {
management: DocLinksStart['links']['management'];
};
};
}
/**
* An interface containing a collection of Kibana plugins and services required to
* render a {@link FieldRow} component and its dependents.
*/
export type FieldRowKibanaDependencies = KibanaDependencies & FieldInputKibanaDependencies;
/**
* An `onChange` handler for a {@link FieldRow} component.
* @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;

View file

@ -0,0 +1,14 @@
---
id: management/settings/fieldDefinition
slug: /management/settings/field-definition
title: Management Settings Field Definition
description: A package containing utilities for creating and examining Field Definitions from Advanced Settings.
tags: ['management', 'settings']
date: 2023-08-31
---
## Description
This package contains utilities for creating and examining Field Definitions from Advanced Settings.
Since a raw `UiSetting` is not type-safe and can be difficult to work with in the UX, this `FieldDefinition` provides a type-safe abstraction over the raw `UiSetting` _and_ provides additional UI-centric information derived from the setting.

View file

@ -0,0 +1,151 @@
/*
* 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.
*/
/*
* 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 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';
/**
* The portion of the setting name that defines the category of the setting.
*/
export const CATEGORY_FIELD = 'category';
/**
* The default category for a setting, if not supplied.
*/
export const DEFAULT_CATEGORY = 'general';
const mapWords = (name?: string): string =>
words(name ?? '')
.map((word) => word.toLowerCase())
.join(' ');
/**
* Derive the aria-label for a given setting based on its name and category.
*/
const getAriaLabel = (name: string = '') => {
const query = Query.parse(name);
if (query.hasOrFieldClause(CATEGORY_FIELD)) {
const categories = query.getOrFieldClause(CATEGORY_FIELD);
const termValue = mapWords(query.removeOrFieldClauses(CATEGORY_FIELD).text);
if (!categories || !Array.isArray(categories.value)) {
return termValue;
}
let categoriesQuery = Query.parse('');
categories.value.forEach((v) => {
categoriesQuery = categoriesQuery.addOrFieldValue(CATEGORY_FIELD, v);
});
return `${termValue} ${categoriesQuery.text}`;
}
return mapWords(name);
};
/**
* Parameters for converting a {@link UiSettingMetadata} object into a {@link FieldDefinition}
* for use in the UI.
* @internal
*/
interface GetDefinitionParams<T extends SettingType> {
/** The id of the field. */
id: string;
/** The source setting from Kibana. */
setting: UiSettingMetadata<T>;
/** Optional parameters */
params?: {
/** True if the setting it custom, false otherwise */
isCustom?: boolean;
/** True if the setting is overridden in Kibana, false otherwise. */
isOverridden?: boolean;
};
}
/**
* Create a {@link FieldDefinition} from a {@link UiSettingMetadata} object for use
* in the UI.
*
* @param parameters The {@link GetDefinitionParams} for creating the {@link FieldDefinition}.
*/
export const getFieldDefinition = <T extends SettingType>(
parameters: GetDefinitionParams<T>
): FieldDefinition<T> => {
const { id, setting, params = { isCustom: false, isOverridden: false } } = parameters;
const {
category,
deprecation,
description,
metric,
name,
optionLabels,
options: optionValues,
order,
readonly,
requiresPageReload,
type,
userValue: savedValue,
value: defaultValue,
} = setting;
const { isCustom, isOverridden } = params;
const categories = category && category.length ? category : [DEFAULT_CATEGORY];
const options = {
values: optionValues || [],
labels: optionLabels || {},
};
const defaultValueDisplay =
defaultValue === undefined || defaultValue === null || defaultValue === ''
? 'null'
: String(defaultValue);
const definition: FieldDefinition<T> = {
ariaAttributes: {
ariaLabel: getAriaLabel(name),
// ariaDescribedBy: unsavedChange.value ? `${groupId} ${unsavedId}` : undefined,
},
categories,
defaultValue,
defaultValueDisplay,
deprecation,
description,
displayName: name || id,
groupId: `${name || id}-group`,
id,
isCustom: isCustom || false,
isDefaultValue: isEqual(defaultValue, setting.userValue),
isOverridden: isOverridden || false,
isReadOnly: !!readonly,
metric,
name: name || id,
options,
order,
requiresPageReload: !!requiresPageReload,
savedValue,
type,
unsavedFieldId: `${id}-unsaved`,
};
// TODO: clintandrewhall - add validation (e.g. `select` contains non-empty `options`)
return definition;
};

View file

@ -0,0 +1,32 @@
/*
* 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 {
isArrayFieldDefinition,
isArrayFieldUnsavedChange,
isBooleanFieldDefinition,
isBooleanFieldUnsavedChange,
isColorFieldDefinition,
isColorFieldUnsavedChange,
isImageFieldDefinition,
isImageFieldUnsavedChange,
isJsonFieldDefinition,
isJsonFieldUnsavedChange,
isMarkdownFieldDefinition,
isMarkdownFieldUnsavedChange,
isNumberFieldDefinition,
isNumberFieldUnsavedChange,
isSelectFieldDefinition,
isSelectFieldUnsavedChange,
isStringFieldDefinition,
isStringFieldUnsavedChange,
isUndefinedFieldDefinition,
isUndefinedFieldUnsavedChange,
} from './is';
export { getFieldDefinition } from './get_definition';

View file

@ -0,0 +1,114 @@
/*
* 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.
*/
// This file is enormous and looks a bit excessive, but it's actually a collection
// of type guards.
//
// In the past, the UI would key off of the `type` property of a UISetting to do
// its work. This was not at all type-safe, and it was easy to make mistakes.
//
// These type guards narrow a given {@link FieldDefinition} to its correct Typescript
// interface. What's interesting is that these guards compile to checking the `type`
// property of the object-- just as we did before-- but with the benefit of Typescript.
import {
ArrayFieldDefinition,
BooleanFieldDefinition,
ColorFieldDefinition,
FieldDefinition,
ImageFieldDefinition,
JsonFieldDefinition,
MarkdownFieldDefinition,
NumberFieldDefinition,
SelectFieldDefinition,
SettingType,
StringFieldDefinition,
UndefinedFieldDefinition,
} from '@kbn/management-settings-types';
/** Simplifed type for a {@link FieldDefinition} */
type Definition = Pick<FieldDefinition<SettingType>, 'type'>;
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link ArrayFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isArrayFieldDefinition = (d: Definition): d is ArrayFieldDefinition =>
d.type === 'array';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link BooleanFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isBooleanFieldDefinition = (d: Definition): d is BooleanFieldDefinition =>
d.type === 'boolean';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link ColorFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isColorFieldDefinition = (d: Definition): d is ColorFieldDefinition =>
d.type === 'color';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link ImageFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isImageFieldDefinition = (d: Definition): d is ImageFieldDefinition =>
d.type === 'image';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link JsonFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isJsonFieldDefinition = (d: Definition): d is JsonFieldDefinition => d.type === 'json';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link MarkdownFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isMarkdownFieldDefinition = (d: Definition): d is MarkdownFieldDefinition =>
d.type === 'markdown';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link NumberFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isNumberFieldDefinition = (d: Definition): d is NumberFieldDefinition =>
d.type === 'number';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link SelectFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isSelectFieldDefinition = (d: Definition): d is SelectFieldDefinition =>
d.type === 'select';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link StringFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isStringFieldDefinition = (d: Definition): d is StringFieldDefinition =>
d.type === 'string';
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link UndefinedFieldDefinition},
* `false` otherwise.
* @param d The {@link FieldDefinition} to check.
*/
export const isUndefinedFieldDefinition = (d: Definition): d is UndefinedFieldDefinition =>
d.type === 'undefined';

View file

@ -0,0 +1,33 @@
/*
* 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 {
isArrayFieldUnsavedChange,
isBooleanFieldUnsavedChange,
isColorFieldUnsavedChange,
isImageFieldUnsavedChange,
isJsonFieldUnsavedChange,
isMarkdownFieldUnsavedChange,
isNumberFieldUnsavedChange,
isSelectFieldUnsavedChange,
isStringFieldUnsavedChange,
isUndefinedFieldUnsavedChange,
} from './unsaved_change';
export {
isArrayFieldDefinition,
isBooleanFieldDefinition,
isColorFieldDefinition,
isImageFieldDefinition,
isJsonFieldDefinition,
isMarkdownFieldDefinition,
isNumberFieldDefinition,
isSelectFieldDefinition,
isStringFieldDefinition,
isUndefinedFieldDefinition,
} from './field_definition';

View file

@ -0,0 +1,115 @@
/*
* 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.
*/
// This file is enormous and looks a bit excessive, but it's actually a collection
// of type guards.
//
// In the past, the UI would key off of the `type` property of a UISetting to do
// its work. This was not at all type-safe, and it was easy to make mistakes.
//
// These type guards narrow a given {@link UnsavedFieldChange} to its correct Typescript
// interface. What's interesting is that these guards compile to checking the `type`
// property of the object-- just as we did before-- but with the benefit of Typescript.
import {
ArrayUnsavedFieldChange,
BooleanUnsavedFieldChange,
ColorUnsavedFieldChange,
ImageUnsavedFieldChange,
JsonUnsavedFieldChange,
MarkdownUnsavedFieldChange,
NumberUnsavedFieldChange,
SelectUnsavedFieldChange,
StringUnsavedFieldChange,
UndefinedUnsavedFieldChange,
SettingType,
UnsavedFieldChange,
} from '@kbn/management-settings-types';
/** Simplifed type for a {@link UnsavedFieldChange} */
type Change = UnsavedFieldChange<SettingType>;
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link ArrayUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isArrayFieldUnsavedChange = (c?: Change): c is ArrayUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'array';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link BooleanUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isBooleanFieldUnsavedChange = (c?: Change): c is BooleanUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'boolean';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link ColorUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isColorFieldUnsavedChange = (c?: Change): c is ColorUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'color';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link ImageUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isImageFieldUnsavedChange = (c?: Change): c is ImageUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'image';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link JsonUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isJsonFieldUnsavedChange = (c?: Change): c is JsonUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'json';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link MarkdownUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isMarkdownFieldUnsavedChange = (c?: Change): c is MarkdownUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'markdown';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link NumberUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isNumberFieldUnsavedChange = (c?: Change): c is NumberUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'number';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link SelectUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isSelectFieldUnsavedChange = (c?: Change): c is SelectUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'select';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link StringUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isStringFieldUnsavedChange = (c?: Change): c is StringUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'string';
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link UndefinedUnsavedFieldChange},
* `false` otherwise.
* @param c The {@link FieldUnsavedChange} to check.
*/
export const isUndefinedFieldUnsavedChange = (c?: Change): c is UndefinedUnsavedFieldChange =>
!c || c.type === undefined || c.type === 'undefined';

View file

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

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/management-settings-field-definition",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,100 @@
/*
* 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

@ -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 { getDefaultValue, getUserValue, IMAGE } from './values';
export { useFieldDefinition } from './field_definition';

View file

@ -0,0 +1,88 @@
/*
* 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 } from '@kbn/management-settings-types';
const LOREM =
'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.';
const JSON_DEFAULT = `{
"foo": "bar"
}`;
const JSON_USER = `{
"foo": "baz",
"bar": "qux"
}`;
const MARKDOWN = `# Heading 1
${LOREM.split('. ')
.map((sentence) => `- ${sentence}.`)
.join('\n')}
`;
/**
* A predefined Image as a Base64 string.
*/
export const IMAGE = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAADMElEQVR4nOzVwQnAIBQFQYXff81RUkQCOyDj1YOPnbXWPmeTRef+/3O/OyBjzh3CD95BfqICMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMO0TAAD//2Anhf4QtqobAAAAAElFTkSuQmCC
`;
/**
* Given a {@link SettingType}, returns a compatible user-defined value.
*/
export const getUserValue = (type: SettingType) => {
switch (type) {
case 'array':
return ['foo', 'bar'];
case 'boolean':
return true;
case 'color':
return '#654321';
case 'image':
return IMAGE;
case 'json':
return JSON_USER;
case 'markdown':
return MARKDOWN;
case 'number':
return 54321;
case 'select':
return 'option2';
case 'string':
default:
return 'some user value';
}
};
/**
* Given a {@link SettingType}, returns a compatible default value.
*/
export const getDefaultValue = (type: SettingType) => {
switch (type) {
case 'array':
return ['foo', 'bar', 'baz'];
case 'boolean':
return false;
case 'color':
return '#123456';
case 'image':
return '';
case 'json':
return JSON_DEFAULT;
case 'markdown':
return '';
case 'number':
return 12345;
case 'select':
return 'option1';
case 'string':
default:
return 'some default';
}
};

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/management-settings-types",
]
}

View file

@ -8,12 +8,10 @@
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/packages/kbn-management/settings/section_registry'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/packages/kbn-management/settings/section_registry',
rootDir: '../../..',
roots: ['<rootDir>/packages/kbn-management/settings'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/packages/kbn-management/settings',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/packages/kbn-management/settings/section_registry/**/*.{ts,tsx}',
],
collectCoverageFrom: ['<rootDir>/packages/kbn-management/settings/**/*.{ts,tsx}'],
coveragePathIgnorePatterns: ['__stories__', '.stories.tsx', 'storybook', 'mocks'],
};

Some files were not shown because too many files have changed in this diff Show more