diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a53f01d6082cc..859d976720aac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/package.json b/package.json index b02c4b9f83623..e365e7f5e8b92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/kbn-management/settings/components/field_input/README.mdx b/packages/kbn-management/settings/components/field_input/README.mdx new file mode 100644 index 0000000000000..8deb8f1981c76 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/README.mdx @@ -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. \ No newline at end of file diff --git a/packages/kbn-management/settings/components/field_input/__stories__/array_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/array_input.stories.tsx new file mode 100644 index 0000000000000..cd1198343aa14 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/array_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/boolean_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/boolean_input.stories.tsx new file mode 100644 index 0000000000000..ace55571e5793 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/boolean_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/color_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/color_input.stories.tsx new file mode 100644 index 0000000000000..ba32182db8a3d --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/color_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/common.tsx b/packages/kbn-management/settings/components/field_input/__stories__/common.tsx new file mode 100644 index 0000000000000..c3e167323d59f --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/common.tsx @@ -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 = Pick, '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; + settingFields?: Partial>; +} + +/** + * 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) => ( + + + + + + ), + ], + } as ComponentMeta); + +/** + * 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) => { + const setting: UiSettingMetadata = { + type, + value, + userValue: value, + ...params.settingFields, + }; + + const [field, unsavedChange, onChangeFn] = useFieldDefinition(setting); + + const onChange: OnChangeFn = (newChange) => { + onChangeFn(newChange); + }; + return ( + + ); + }; + + Story.args = { + value: getDefaultValue(type), + ...params.argTypes, + ...storyArgs, + }; + + return Story; +}; diff --git a/packages/kbn-management/settings/components/field_input/__stories__/image_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/image_input.stories.tsx new file mode 100644 index 0000000000000..28a87465c680a --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/image_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/json_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/json_input.stories.tsx new file mode 100644 index 0000000000000..f00fca4e5e9a5 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/json_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/markdown_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/markdown_input.stories.tsx new file mode 100644 index 0000000000000..ef0f9d358462f --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/markdown_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/number_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/number_input.stories.tsx new file mode 100644 index 0000000000000..1d6aaa8952a3f --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/number_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/select_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/select_input.stories.tsx new file mode 100644 index 0000000000000..c7571494e7ca8 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/select_input.stories.tsx @@ -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 }); diff --git a/packages/kbn-management/settings/components/field_input/__stories__/text_input.stories.tsx b/packages/kbn-management/settings/components/field_input/__stories__/text_input.stories.tsx new file mode 100644 index 0000000000000..39de404bde404 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/__stories__/text_input.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_input/code_editor.tsx b/packages/kbn-management/settings/components/field_input/code_editor.tsx new file mode 100644 index 0000000000000..3f46778917fdd --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/code_editor.tsx @@ -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; +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 ( + + ); +}; diff --git a/packages/kbn-management/settings/components/field_input/field_input.test.tsx b/packages/kbn-management/settings/components/field_input/field_input.test.tsx new file mode 100644 index 0000000000000..9bbac96b7c12c --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/field_input.test.tsx @@ -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) => ( + { + if (onChange) { + onChange(e.target.value, e as any); + } + }} + /> + ), +})); + +describe('FieldInput', () => { + const getDefaultProps = (type: SettingType): FieldInputProps => { + let options; + if (type === 'select') { + options = { + labels: { + option1: 'Option 1', + option2: 'Option 2', + option3: 'Option 3', + }, + values: ['option1', 'option2', 'option3'], + }; + } + + const props: FieldInputProps = { + field: { + id: 'test', + name, + type, + ariaAttributes: { + ariaLabel: 'Test', + }, + options, + } as FieldDefinition, + onChange: jest.fn(), + }; + + return props; + }; + + it('renders without errors', () => { + const { container } = render(wrap()); + expect(container).toBeInTheDocument(); + }); + + it('renders a TextInput for a string field', () => { + const props = getDefaultProps('string'); + const { getByTestId } = render(wrap()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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( + } + /> + ) + ) + ).toThrowError(`Unsaved change for ${type} mismatch: number`); + }); + + expect(() => + render( + wrap( + } + /> + ) + ) + ).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())).toThrowError( + 'Unknown or incompatible field type: foobar' + ); + + consoleMock.mockRestore(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/field_input.tsx b/packages/kbn-management/settings/components/field_input/field_input.tsx new file mode 100644 index 0000000000000..301be48ee5141 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/field_input.tsx @@ -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 { + /** The {@link FieldDefinition} for the component. */ + field: FieldDefinition; + /** An {@link UnsavedFieldChange} for the component, if any. */ + unsavedChange?: UnsavedFieldChange; + /** The `onChange` handler for the input. */ + onChange: OnChangeFn; + /** True if the input is disabled, false otherwise. */ + isDisabled?: boolean; + /** True if the 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 = (props: FieldInputProps) => { + const { + field, + unsavedChange, + isDisabled = false, + isInvalid = false, + onChange: onChangeProp, + } = props; + const { id, name, ariaAttributes } = field; + + const inputProps = { + ...ariaAttributes, + id, + isDisabled, + isInvalid, + name, + }; + + // These checks might seem excessive or redundant, but they are necessary to ensure that + // the types are honored correctly using type guards. These checks get compiled down to + // checks against the `type` property-- which we were doing in the previous code, albeit + // in an unenforceable way. + // + // Based on the success of a check, we can render the `FieldInput` in a indempotent and + // type-safe way. + // + if (isArrayFieldDefinition(field)) { + // If the composing component mistakenly provides an incompatible `UnsavedFieldChange`, + // we can throw an `Error`. We might consider switching to a `console.error` and not + // rendering the input, but that might be less helpful. + if (!isArrayFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + 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 ; + } + + if (isBooleanFieldDefinition(field)) { + if (!isBooleanFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + const [value] = getInputValue(field, unsavedChange); + const onChange = onChangeProp as OnChangeFn<'boolean'>; + + return ; + } + + if (isColorFieldDefinition(field)) { + if (!isColorFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + const [value] = getInputValue(field, unsavedChange); + const onChange = onChangeProp as OnChangeFn<'color'>; + + return ; + } + + 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 ( + + ); + } + + if (isJsonFieldDefinition(field)) { + if (!isJsonFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + const [value] = getInputValue(field, unsavedChange); + const onChange = onChangeProp as OnChangeFn<'json'>; + + return ( + + ); + } + + if (isMarkdownFieldDefinition(field)) { + if (!isMarkdownFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + const [value] = getInputValue(field, unsavedChange); + const onChange = onChangeProp as OnChangeFn<'markdown'>; + + return ( + + ); + } + + if (isNumberFieldDefinition(field)) { + if (!isNumberFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + const [value] = getInputValue(field, unsavedChange); + const onChange = onChangeProp as OnChangeFn<'number'>; + + return ; + } + + 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 ; + } + + if (isStringFieldDefinition(field)) { + if (!isStringFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + const [value] = getInputValue(field, unsavedChange); + const onChange = onChangeProp as OnChangeFn<'string'>; + + return ; + } + + if (isUndefinedFieldDefinition(field)) { + if (!isUndefinedFieldUnsavedChange(unsavedChange)) { + throw getMismatchError(field.type, unsavedChange?.type); + } + + const [value] = getInputValue(field, unsavedChange); + return ; + } + + throw new Error(`Unknown or incompatible field type: ${field.type}`); +}; diff --git a/packages/kbn-management/settings/components/field_input/index.ts b/packages/kbn-management/settings/components/field_input/index.ts new file mode 100644 index 0000000000000..8570f9af23c93 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx new file mode 100644 index 0000000000000..2b420d39ee2a5 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/array_input.test.tsx @@ -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()); + expect(container).toBeInTheDocument(); + }); + + it('renders an array of strings', () => { + render(wrap()); + expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toHaveValue('foo, bar'); + }); + + it('formats array when blurred', () => { + render(wrap()); + 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()); + 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()); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + expect(input).toBeDisabled(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/array_input.tsx b/packages/kbn-management/settings/components/field_input/input/array_input.tsx new file mode 100644 index 0000000000000..d5e4d8f202ec5 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/array_input.tsx @@ -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) => { + const blurValue = event.target.value + .replace(REGEX, ',') + .split(',') + .filter((v) => v !== ''); + onChangeProp({ value: blurValue }); + setValue(blurValue.join(', ')); + }; + + return ( + setValue(event.target.value)} + {...{ name, onBlur, value }} + /> + ); +}; diff --git a/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx new file mode 100644 index 0000000000000..6c713261f11ca --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/boolean_input.test.tsx @@ -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()); + expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked(); + }); + + it('renders false', () => { + render(wrap()); + expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).not.toBeChecked(); + }); + + it('calls onChange when toggled', () => { + render(wrap()); + 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); + }); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx b/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx new file mode 100644 index 0000000000000..d95073c096dd6 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/boolean_input.tsx @@ -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 ( + + ) : ( + + ) + } + aria-label={ariaLabel} + aria-describedby={ariaDescribedBy} + checked={!!value} + data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`} + {...{ disabled, name, onChange }} + /> + ); +}; diff --git a/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx b/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx new file mode 100644 index 0000000000000..b5d0f2da8a86c --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/code_editor_input.tsx @@ -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; + +/** + * Props for a {@link CodeEditorInput} component. + */ +export interface CodeEditorInputProps extends InputProps { + /** The default value of the {@link CodeEditor} component. */ + defaultValue?: string; + /** + * The `onChange` event handler, expanded to include both `markdown` + * and `json` + */ + onChange: OnChangeFn; + /** + * The {@link UiSettingType}, expanded to include both `markdown` + * and `json` + */ + 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 ( +
+ +
+ ); +}; diff --git a/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx new file mode 100644 index 0000000000000..d50b58481a885 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/color_picker_input.test.tsx @@ -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()); + expect(container).toBeInTheDocument(); + }); + + it('renders the value prop', () => { + const { getByRole } = render(wrap()); + const input = getByRole('textbox'); + expect(input).toHaveValue('#000000'); + }); + + it('calls the onChange prop when the value changes', () => { + const { getByRole } = render(wrap()); + 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()); + const input = getByRole('textbox'); + expect(input).toBeDisabled(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx b/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx new file mode 100644 index 0000000000000..b5c5f7d4de616 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/color_picker_input.tsx @@ -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 ( + + ); +}; diff --git a/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx new file mode 100644 index 0000000000000..041d0aba44714 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/image_input.test.tsx @@ -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()); + expect(container).toBeInTheDocument(); + }); + + it('calls the onChange prop when a file is selected', async () => { + const { getByTestId } = render(wrap()); + 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()); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + expect(input).toBeDisabled(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/image_input.tsx b/packages/kbn-management/settings/components/field_input/input/image_input.tsx new file mode 100644 index 0000000000000..b118c538e7b34 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/image_input.tsx @@ -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 => { + 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( + ( + { + 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 ; + } else { + return ( + + ); + } + } +); diff --git a/packages/kbn-management/settings/components/field_input/input/index.ts b/packages/kbn-management/settings/components/field_input/input/index.ts new file mode 100644 index 0000000000000..2790604feb9e9 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx new file mode 100644 index 0000000000000..04108a3259738 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/json_editor_input.test.tsx @@ -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) => ( + { + 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(); + expect(container).toBeInTheDocument(); + }); + + it('renders the value prop', () => { + const { getByTestId } = render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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, + }); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx new file mode 100644 index 0000000000000..4df09c3e5df71 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/markdown_editor_input.test.tsx @@ -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) => ( + { + 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(); + expect(container).toBeInTheDocument(); + }); + + it('renders the value prop', () => { + const { getByTestId } = render(); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + expect(input).toHaveValue(initialValue); + }); + + it('calls the onChange prop when the value changes', () => { + const { getByTestId } = render(); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + fireEvent.change(input, { target: { value: '# New Markdown Title' } }); + expect(defaultProps.onChange).toHaveBeenCalledWith({ value: '# New Markdown Title' }); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx new file mode 100644 index 0000000000000..2df3bbc96254f --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/number_input.test.tsx @@ -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()); + expect(container).toBeInTheDocument(); + }); + + it('renders the value prop', () => { + const { getByTestId } = render(wrap()); + 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()); + 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()); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + expect(input).toBeDisabled(); + }); + + it('recovers if value is null', () => { + const { getByTestId } = render(wrap()); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + waitFor(() => expect(input).toHaveValue(undefined)); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/number_input.tsx b/packages/kbn-management/settings/components/field_input/input/number_input.tsx new file mode 100644 index 0000000000000..8d4862fa5e52e --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/number_input.tsx @@ -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) => + 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 ( + + ); +}; diff --git a/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx new file mode 100644 index 0000000000000..fe6fa934ab5bb --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/select_input.test.tsx @@ -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()); + expect(container).toBeInTheDocument(); + }); + + it('renders the value prop', () => { + const { getByTestId } = render(wrap()); + 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()); + 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()); + 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())).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()); + + expect(container).toBeInTheDocument(); + }); + + it('recovers if value is null', () => { + const props = { + ...defaultProps, + value: null, + } as SelectInputProps; + const { container } = render(wrap()); + + expect(container).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/select_input.tsx b/packages/kbn-management/settings/components/field_input/input/select_input.tsx new file mode 100644 index 0000000000000..4ca8fdf21532d --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/select_input.tsx @@ -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; + /** Specify the option values. */ + optionValues: Array; +} + +/** + * 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) => { + 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 ( + + ); +}; diff --git a/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx b/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx new file mode 100644 index 0000000000000..d4dee9f32cdf6 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/text_input.test.tsx @@ -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(); + expect(container).toBeInTheDocument(); + }); + + it('renders the value prop', () => { + const { getByTestId } = render(); + 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(); + 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(); + const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`); + expect(input).toBeDisabled(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_input/input/text_input.tsx b/packages/kbn-management/settings/components/field_input/input/text_input.tsx new file mode 100644 index 0000000000000..aa1dc913eeeea --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/input/text_input.tsx @@ -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) => + onChangeProp({ value: event.target.value }); + + return ( + + ); +}; diff --git a/packages/kbn-management/settings/components/field_input/kibana.jsonc b/packages/kbn-management/settings/components/field_input/kibana.jsonc new file mode 100644 index 0000000000000..625ab3cc564b9 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/management-settings-components-field-input", + "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" +} diff --git a/packages/kbn-management/settings/components/field_input/mocks/context.mock.tsx b/packages/kbn-management/settings/components/field_input/mocks/context.mock.tsx new file mode 100644 index 0000000000000..daf926561bc84 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/mocks/context.mock.tsx @@ -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 }) => {children}, + }; + 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 ( + + {children} + + ); +}; + +export const wrap = ( + component: JSX.Element, + services: FieldInputServices = createFieldInputServicesMock() +) => ( + + {component} + +); diff --git a/packages/kbn-management/settings/components/field_input/mocks/index.ts b/packages/kbn-management/settings/components/field_input/mocks/index.ts new file mode 100644 index 0000000000000..8eb7547c59584 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/mocks/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { TestWrapper, createFieldInputServicesMock, wrap } from './context.mock'; + +export type { FieldInputProvider } from '../services'; +export type { FieldInputServices } from '../types'; diff --git a/packages/kbn-management/settings/components/field_input/package.json b/packages/kbn-management/settings/components/field_input/package.json new file mode 100644 index 0000000000000..ca9dda8f8b384 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/components/field_input/services.tsx b/packages/kbn-management/settings/components/field_input/services.tsx new file mode 100644 index 0000000000000..b76c9b7a9a6a5 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/services.tsx @@ -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(null); + +/** + * React Provider that provides services to a {@link FieldInput} component and its dependents. + */ +export const FieldInputProvider: FC = ({ 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 {children}; +}; + +/** + * Kibana-specific Provider that maps Kibana plugins and services to a {@link FieldInputProvider}. + */ +export const FieldInputKibanaProvider: FC = ({ + children, + toasts, +}) => { + return ( + toasts.addDanger(message), + }} + > + {children} + + ); +}; + +/** + * 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; +}; diff --git a/packages/kbn-management/settings/components/field_input/setup_tests.ts b/packages/kbn-management/settings/components/field_input/setup_tests.ts new file mode 100644 index 0000000000000..8d1acb9232934 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/setup_tests.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom'; diff --git a/packages/kbn-management/settings/components/field_input/tsconfig.json b/packages/kbn-management/settings/components/field_input/tsconfig.json new file mode 100644 index 0000000000000..a6fe848abc2a9 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/tsconfig.json @@ -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", + ] +} diff --git a/packages/kbn-management/settings/components/field_input/types.ts b/packages/kbn-management/settings/components/field_input/types.ts new file mode 100644 index 0000000000000..73e676785e6b9 --- /dev/null +++ b/packages/kbn-management/settings/components/field_input/types.ts @@ -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; +} + +/** + * Props passed to a {@link FieldInput} component. + */ +export interface InputProps | null> { + id: string; + ariaDescribedBy?: string; + ariaLabel: string; + isDisabled?: boolean; + isInvalid?: boolean; + value?: V; + name: string; + /** The `onChange` handler. */ + onChange: OnChangeFn; +} + +/** + * Parameters for the {@link OnChangeFn} handler. + */ +export interface OnChangeParams { + /** The value provided to the handler. */ + value?: KnownTypeToValue | null; + /** An error message, if one occurred. */ + error?: string; + /** True if the format of a change is not valid, false otherwise. */ + isInvalid?: boolean; +} + +/** + * A function that is called when the value of a {@link FieldInput} changes. + * @param params The {@link OnChangeParams} parameters passed to the handler. + */ +export type OnChangeFn = (params: OnChangeParams) => void; diff --git a/packages/kbn-management/settings/components/field_row/README.mdx b/packages/kbn-management/settings/components/field_row/README.mdx new file mode 100644 index 0000000000000..6fe238938407c --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/README.mdx @@ -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: + +
Advanced Settings as a form.
+ +*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. + +
Anatomy of a `FormRow`
+ +*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. \ No newline at end of file diff --git a/packages/kbn-management/settings/components/field_row/__stories__/array_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/array_field.stories.tsx new file mode 100644 index 0000000000000..dfe384fdd2349 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/array_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/boolean_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/boolean_field.stories.tsx new file mode 100644 index 0000000000000..0d663a26cb5f8 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/boolean_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/color_picker_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/color_picker_field.stories.tsx new file mode 100644 index 0000000000000..61b0033d19175 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/color_picker_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/common.tsx b/packages/kbn-management/settings/components/field_row/__stories__/common.tsx new file mode 100644 index 0000000000000..a18592ca867b2 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/common.tsx @@ -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 + extends Pick, '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 = {} +) => + ({ + 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) => ( + + + + + + ), + ], + } as ComponentMeta); + +/** + * 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> +) => { + const Story = ({ + isCustom, + isDeprecated, + isOverridden, + isSavingEnabled, + userValue, + value, + }: StoryProps) => { + const setting: UiSettingMetadata = { + type, + value, + userValue, + name: `Some ${type} setting`, + ...settingFields, + }; + + const [field, unsavedChange, onChangeFn] = useFieldDefinition(setting, { + isCustom, + isDeprecated, + isOverridden, + }); + + const onChange: OnChangeFn = (_key, change) => { + const { error, isInvalid, unsavedValue } = change; + onChangeFn({ error: error === null ? undefined : error, isInvalid, value: unsavedValue }); + }; + + return ; + }; + + Story.args = { + userValue: getUserValue(type), + value: getDefaultValue(type), + ...storyArgs, + }; + + return Story; +}; diff --git a/packages/kbn-management/settings/components/field_row/__stories__/image_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/image_field.stories.tsx new file mode 100644 index 0000000000000..26975a2c8e4af --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/image_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/json_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/json_field.stories.tsx new file mode 100644 index 0000000000000..8a941a3abd804 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/json_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/markdown_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/markdown_field.stories.tsx new file mode 100644 index 0000000000000..0a858d5ec5ae7 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/markdown_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/number_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/number_field.stories.tsx new file mode 100644 index 0000000000000..dc97a11386afc --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/number_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/select_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/select_field.stories.tsx new file mode 100644 index 0000000000000..299297f341282 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/select_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/__stories__/text_field.stories.tsx b/packages/kbn-management/settings/components/field_row/__stories__/text_field.stories.tsx new file mode 100644 index 0000000000000..09ca6ada1d88d --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/__stories__/text_field.stories.tsx @@ -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); diff --git a/packages/kbn-management/settings/components/field_row/assets/form_row.png b/packages/kbn-management/settings/components/field_row/assets/form_row.png new file mode 100644 index 0000000000000..e880adf032d8e Binary files /dev/null and b/packages/kbn-management/settings/components/field_row/assets/form_row.png differ diff --git a/packages/kbn-management/settings/components/field_row/assets/page.png b/packages/kbn-management/settings/components/field_row/assets/page.png new file mode 100644 index 0000000000000..9654e548193d4 Binary files /dev/null and b/packages/kbn-management/settings/components/field_row/assets/page.png differ diff --git a/packages/kbn-management/settings/components/field_row/description/default_value.test.tsx b/packages/kbn-management/settings/components/field_row/description/default_value.test.tsx new file mode 100644 index 0000000000000..49bb85fb3cdcd --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/description/default_value.test.tsx @@ -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( + + ) + ); + + expect(container).toBeInTheDocument(); + }); + + it('renders nothing if the default value is set', () => { + const { container } = render( + wrap( + + ) + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('does not render a code block for string fields', () => { + const { queryByTestId, getByText } = render( + wrap( + + ) + ); + 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( + + ) + ); + const input = getByTestId(`${DATA_TEST_SUBJ_DEFAULT_DISPLAY_PREFIX}-test`); + expect(input).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/description/default_value.tsx b/packages/kbn-management/settings/components/field_row/description/default_value.tsx new file mode 100644 index 0000000000000..75fb9c4c7bdc4 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/description/default_value.tsx @@ -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 { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Pick, 'id' | 'type' | 'isDefaultValue' | 'defaultValueDisplay'>; +} + +/** + * Component for displaying the default value of a {@link FieldDefinition} + * in the {@link FieldRow}. + */ +export const FieldDefaultValue = ({ field }: FieldDefaultValueProps) => { + if (field.isDefaultValue) { + return null; + } + + const { defaultValueDisplay: display, id } = field; + + let value = {display}; + + if (isJsonFieldDefinition(field) || isMarkdownFieldDefinition(field)) { + value = ( + = 500 ? 300 : undefined} + > + {display} + + ); + } + + return ( + + + + ); +}; diff --git a/packages/kbn-management/settings/components/field_row/description/deprecation.test.tsx b/packages/kbn-management/settings/components/field_row/description/deprecation.test.tsx new file mode 100644 index 0000000000000..73e70df48e48f --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/description/deprecation.test.tsx @@ -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( + + ) + ); + expect(container).toBeInTheDocument(); + }); + + it('renders nothing if there is no deprecation', () => { + const { container } = render(wrap()); + expect(container.firstChild).toBeNull(); + }); + + it('renders a warning badge if there is a deprecation', () => { + const { getByText } = render( + wrap( + + ) + ); + const badge = getByText('Deprecated'); + expect(badge).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/description/deprecation.tsx b/packages/kbn-management/settings/components/field_row/description/deprecation.tsx new file mode 100644 index 0000000000000..664f9e3e96047 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/description/deprecation.tsx @@ -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 = Pick, 'id' | 'deprecation' | 'name'>; + +/** + * Props for a {@link FieldDeprecation} component. + */ +export interface FieldDeprecationProps { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Field; +} + +/** + * + */ +export const FieldDeprecation = ({ field }: FieldDeprecationProps) => { + const { links } = useServices(); + const { deprecation, name, id } = field; + + if (!deprecation) { + return null; + } + + return ( +
+ + { + window.open(links[deprecation!.docLinksKey], '_blank'); + }} + onClickAriaLabel={i18n.translate('management.settings.field.deprecationClickAreaLabel', { + defaultMessage: 'Click to view deprecation documentation for {name}.', + values: { + name, + }, + })} + > + Deprecated + + +
+ ); +}; diff --git a/packages/kbn-management/settings/components/field_row/description/description.test.tsx b/packages/kbn-management/settings/components/field_row/description/description.test.tsx new file mode 100644 index 0000000000000..859a530f3ccdd --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/description/description.test.tsx @@ -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( + + ) + ); + expect(getByText(description)).toBeInTheDocument(); + }); + + it('renders no description without one', () => { + const { queryByText } = render(wrap()); + expect(queryByText(description)).toBeNull(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/description/description.tsx b/packages/kbn-management/settings/components/field_row/description/description.tsx new file mode 100644 index 0000000000000..86529f366a321 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/description/description.tsx @@ -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 = Pick< + FieldDefinition, + | 'defaultValue' + | 'defaultValueDisplay' + | 'description' + | 'id' + | 'isDefaultValue' + | 'name' + | 'savedValue' + | 'type' +>; + +/** + * Props for a {@link FieldDescription} component. + */ +export interface FieldDescriptionProps { + field: Field; + unsavedChange?: UnsavedFieldChange; +} + +/** + * Component for displaying the description of a {@link FieldDefinition}. + */ +export const FieldDescription = ({ + field, + unsavedChange, +}: FieldDescriptionProps) => { + 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 = ( +
+ ); + } + + if (content) { + content = ( + + {content} + + ); + } + + return ( +
+ + {content} + +
+ ); +}; diff --git a/packages/kbn-management/settings/components/field_row/description/index.ts b/packages/kbn-management/settings/components/field_row/description/index.ts new file mode 100644 index 0000000000000..e0b513037b6d1 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/description/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/components/field_row/field_row.styles.ts b/packages/kbn-management/settings/components/field_row/field_row.styles.ts new file mode 100644 index 0000000000000..ece92a9fbd1aa --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/field_row.styles.ts @@ -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 { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Pick, 'savedValue'>; + /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ + unsavedChange?: UnsavedFieldChange; +} + +/** + * A React hook that provides stateful `css` classes for the {@link FieldRow} component. + */ +export const useFieldStyles = ({ field, unsavedChange }: Params) => { + 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}; + } + `, + }; +}; diff --git a/packages/kbn-management/settings/components/field_row/field_row.test.tsx b/packages/kbn-management/settings/components/field_row/field_row.test.tsx new file mode 100644 index 0000000000000..481cb43b6fcf9 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/field_row.test.tsx @@ -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 = { + 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 = { + 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 = { + 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 = { + 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; +}; + +const settings: Omit = { + 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( + + ) + ); + + expect(container).toBeInTheDocument(); + }); + + it('should render default value if there is no user value set', () => { + const { getByTestId } = render( + wrap( + + ) + ); + + 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( + + ) + ); + 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( + + ) + ); + 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( + + ) + ); + + 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( + + ) + ); + + expect(getByText('Custom setting')).toBeInTheDocument(); + }); + + it('should render unsaved value if there are unsaved changes', () => { + const { getByTestId, getByAltText } = render( + wrap( + + ) + ); + + 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() + ); + + 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() + ); + + 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() + ); + + 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( + + ) + ); + + 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( + + ) + ); + + 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, + }); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/field_row.tsx b/packages/kbn-management/settings/components/field_row/field_row.tsx new file mode 100644 index 0000000000000..c7f90af8c90fd --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/field_row.tsx @@ -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 { + /** True if saving settings is enabled, false otherwise. */ + isSavingEnabled: boolean; + /** The {@link OnChangeFn} handler. */ + onChange: OnChangeFn; + /** + * The onClear handler, if a value is cleared to an empty or default state. + * @param id The id relating to the field to clear. + */ + onClear?: (id: string) => void; + /** The {@link FieldDefinition} corresponding the setting. */ + field: FieldDefinition; + /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ + unsavedChange?: UnsavedFieldChange; +} + +/** + * Component for displaying a {@link FieldDefinition} in a form row, using a {@link FieldInput}. + * @param props The {@link FieldRowProps} for the {@link FieldRow} component. + */ +export const FieldRow = (props: FieldRowProps) => { + const { isSavingEnabled, onChange: onChangeProp, field, unsavedChange } = props; + const { id, name, groupId, isOverridden, type, unsavedFieldId } = field; + const { cssFieldFormGroup } = useFieldStyles({ + field, + unsavedChange, + }); + + const onChange = (changes: UnsavedFieldChange) => { + onChangeProp(name, changes); + }; + + const resetField = () => { + const { defaultValue: unsavedValue } = field; + return onChange({ type, unsavedValue }); + }; + + const onFieldChange = ({ isInvalid, error, value: unsavedValue }: OnChangeParams) => { + if (error) { + isInvalid = true; + } + + const change = { + type, + isInvalid, + error, + }; + + if (!isUnsavedValue(field, unsavedValue)) { + onChange(change); + } else { + onChange({ + ...change, + unsavedValue, + }); + } + }; + + const title = ; + const description = ; + const error = unsavedChange?.error; + const isInvalid = unsavedChange?.isInvalid; + let unsavedScreenReaderMessage = null; + + const helpText = ( + + ); + + if (unsavedChange) { + unsavedScreenReaderMessage = ( + +

+ {error + ? error + : i18n.translate('management.settings.field.settingIsUnsaved', { + defaultMessage: 'Setting is currently not saved.', + })} +

+
+ ); + } + + return ( + + + + <> + + {unsavedScreenReaderMessage} + + + + + ); +}; diff --git a/packages/kbn-management/settings/components/field_row/index.ts b/packages/kbn-management/settings/components/field_row/index.ts new file mode 100644 index 0000000000000..f54eadd4467ed --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.test.tsx b/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.test.tsx new file mode 100644 index 0000000000000..3c01240a9e9ea --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.test.tsx @@ -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() + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders with a saved value and no unsaved change', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).not.toBeNull(); + }); + + it('renders if there is a saved value and the unsaved value is undefined', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).not.toBeNull(); + }); + + it('renders nothing when there is an unsaved change', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders an aria-label', () => { + const { getByLabelText } = render( + wrap( + + ) + ); + const link = getByLabelText('Change test'); + expect(link).not.toBeNull(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.tsx b/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.tsx new file mode 100644 index 0000000000000..c4e6df6b4521b --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/change_image_link.tsx @@ -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 = Pick< + FieldDefinition, + 'name' | 'defaultValue' | 'type' | 'savedValue' | 'savedValue' | 'ariaAttributes' +>; +/** + * Props for a {@link ChangeImageLink} component. + */ +export interface ChangeImageLinkProps { + /** The {@link ImageFieldDefinition} corresponding the setting. */ + field: Field; + /** The {@link OnChangeFn} event handler. */ + onChange: OnChangeFn; + /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ + unsavedChange?: UnsavedFieldChange; +} + +/** + * Component for rendering a link to change the image in a {@link FieldRow} of + * an {@link ImageFieldDefinition}. + */ +export const ChangeImageLink = ({ + field, + onChange, + unsavedChange, +}: ChangeImageLinkProps) => { + 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 ( + + onChange({ value: defaultValue })} + data-test-subj={`management-settings-changeImage-${name}`} + > + + + + ); + } + + return null; +}; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/index.ts b/packages/kbn-management/settings/components/field_row/input_footer/index.ts new file mode 100644 index 0000000000000..d840b892b9bd8 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/input_footer.tsx b/packages/kbn-management/settings/components/field_row/input_footer/input_footer.tsx new file mode 100644 index 0000000000000..5a2e12f39f6b2 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/input_footer.tsx @@ -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 = Pick< + FieldDefinition, + 'id' | 'name' | 'isOverridden' | 'type' | 'ariaAttributes' | 'isDefaultValue' +>; + +/** + * Props for a {@link FieldInputFooter} component. + */ +export interface FieldInputFooterProps { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Field; + /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ + unsavedChange?: UnsavedFieldChange; + /** The {@link OnChangeFn} handler. */ + onChange: OnChangeFn; + /** 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 = ({ + isSavingEnabled, + field, + onReset, + ...props +}: FieldInputFooterProps) => { + if (field.isOverridden) { + return ; + } + + if (isSavingEnabled) { + return ( + + + + + ); + } + + return null; +}; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.test.tsx b/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.test.tsx new file mode 100644 index 0000000000000..ab894cf013174 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.test.tsx @@ -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( + + ); + expect(container).toBeInTheDocument(); + }); + + it('renders nothing if the field is not overridden', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.tsx b/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.tsx new file mode 100644 index 0000000000000..bff68afb370c2 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/overridden_message.tsx @@ -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 = Pick, 'id' | 'isOverridden' | 'name'>; + +export const DATA_TEST_SUBJ_OVERRIDDEN_PREFIX = 'field-row-input-overridden-message'; + +/** + * Props for a {@link FieldOverriddenMessage} component. + */ +export interface FieldOverriddenMessageProps { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Field; +} + +export const FieldOverriddenMessage = ({ + field, +}: FieldOverriddenMessageProps) => { + if (!field.isOverridden) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/kbn-management/settings/components/field_row/input_footer/reset_link.test.tsx b/packages/kbn-management/settings/components/field_row/input_footer/reset_link.test.tsx new file mode 100644 index 0000000000000..52cf165ab9b9f --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/reset_link.test.tsx @@ -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()); + expect(container).toBeInTheDocument(); + }); + + it('renders nothing if the field is already at its default value', () => { + const { container } = render( + wrap( + + ) + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders a link to reset the field if it is not at its default value', () => { + const { getByText } = render(wrap()); + const link = getByText('Reset to default'); + expect(link).toBeInTheDocument(); + }); + + it('calls the onReset prop when the link is clicked', () => { + const { getByText } = render(wrap()); + const link = getByText('Reset to default'); + fireEvent.click(link); + expect(defaultProps.onReset).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-management/settings/components/field_row/input_footer/reset_link.tsx b/packages/kbn-management/settings/components/field_row/input_footer/reset_link.tsx new file mode 100644 index 0000000000000..2703a4121107d --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/input_footer/reset_link.tsx @@ -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 { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Pick, 'id' | 'isDefaultValue' | 'ariaAttributes'>; + /** A handler for when a field is reset to its default or saved value. */ + onReset: () => void; +} + +export const DATA_TEST_SUBJ_RESET_PREFIX = 'management-settings-resetField'; +/** + * Component for rendering a link to reset a {@link FieldDefinition} to its default + * or saved value. + */ +export const FieldResetLink = ({ + onReset, + field, +}: FieldResetLinkProps) => { + if (field.isDefaultValue) { + return null; + } + + const { + id, + ariaAttributes: { ariaLabel }, + } = field; + + return ( + + + + +     + + ); +}; diff --git a/packages/kbn-management/settings/components/field_row/kibana.jsonc b/packages/kbn-management/settings/components/field_row/kibana.jsonc new file mode 100644 index 0000000000000..ceec221d6a2d2 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/management-settings-components-field-row", + "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" +} diff --git a/packages/kbn-management/settings/components/field_row/mocks/context.tsx b/packages/kbn-management/settings/components/field_row/mocks/context.tsx new file mode 100644 index 0000000000000..f8109b6dd08b1 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/mocks/context.tsx @@ -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 }) => {children}, + }; + 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 ( + + {children} + + ); +}; + +export const wrap = ( + component: JSX.Element, + services: FieldInputServices = createFieldRowServicesMock() +) => {component}; diff --git a/packages/kbn-management/settings/components/field_row/mocks/index.ts b/packages/kbn-management/settings/components/field_row/mocks/index.ts new file mode 100644 index 0000000000000..2fbe57cd37108 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/mocks/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/components/field_row/package.json b/packages/kbn-management/settings/components/field_row/package.json new file mode 100644 index 0000000000000..aa5daf8a30cd7 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/components/field_row/services.tsx b/packages/kbn-management/settings/components/field_row/services.tsx new file mode 100644 index 0000000000000..7d9fab6d87035 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/services.tsx @@ -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(null); + +/** + * React Provider that provides services to a {@link FieldRow} component and its dependents. + */ +export const FieldRowProvider: FC = ({ 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 ( + + {children} + + ); +}; + +/** + * Kibana-specific Provider that maps Kibana plugins and services to a {@link FieldRowProvider}. + */ +export const FieldRowKibanaProvider: FC = ({ + children, + docLinks, + toasts, +}) => { + return ( + + {children} + + ); +}; + +/** + * 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; +}; diff --git a/packages/kbn-management/settings/components/field_row/setup_tests.ts b/packages/kbn-management/settings/components/field_row/setup_tests.ts new file mode 100644 index 0000000000000..8d1acb9232934 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/setup_tests.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom'; diff --git a/packages/kbn-management/settings/components/field_row/title/icon_custom.tsx b/packages/kbn-management/settings/components/field_row/title/icon_custom.tsx new file mode 100644 index 0000000000000..d773eb136b3b0 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/title/icon_custom.tsx @@ -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 { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Pick, 'isCustom'>; +} + +/** + * + */ +export const FieldTitleCustomIcon = ({ field }: TitleProps) => { + if (!field.isCustom) { + return null; + } + + return ( + + } + /> + ); +}; diff --git a/packages/kbn-management/settings/components/field_row/title/icon_unsaved.tsx b/packages/kbn-management/settings/components/field_row/title/icon_unsaved.tsx new file mode 100644 index 0000000000000..bf44a0686d60e --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/title/icon_unsaved.tsx @@ -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 { + /** The {@link FieldDefinition} corresponding the setting. */ + field: Pick, 'id' | 'type' | 'isOverridden' | 'savedValue'>; + /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ + unsavedChange?: UnsavedFieldChange; +} + +/** + * + */ +export const FieldTitleUnsavedIcon = ({ + field, + unsavedChange, +}: TitleProps) => { + 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 ( + + ); +}; diff --git a/packages/kbn-management/settings/components/field_row/title/index.ts b/packages/kbn-management/settings/components/field_row/title/index.ts new file mode 100644 index 0000000000000..f2a757252e699 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/title/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/components/field_row/title/title.tsx b/packages/kbn-management/settings/components/field_row/title/title.tsx new file mode 100644 index 0000000000000..36c6042394287 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/title/title.tsx @@ -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 { + /** The {@link FieldDefinition} corresponding the setting. */ + field: FieldDefinition; + /** Emotion-based `css` for the root React element. */ + css?: Interpolation; + /** Classname for the root React element. */ + className?: string; + /** The {@link UnsavedFieldChange} corresponding to any unsaved change to the field. */ + unsavedChange?: UnsavedFieldChange; +} + +/** + * Component for displaying the `displayName` and status of a {@link FieldDefinition} in + * the {@link FieldRow}. + */ +export const FieldTitle = ({ + field, + unsavedChange, + ...props +}: TitleProps) => { + const { cssFieldTitle } = useFieldStyles({ + field, + unsavedChange, + }); + + return ( + + +

{field.displayName}

+
+ + + + + + +
+ ); +}; diff --git a/packages/kbn-management/settings/components/field_row/tsconfig.json b/packages/kbn-management/settings/components/field_row/tsconfig.json new file mode 100644 index 0000000000000..173fbd57d08b6 --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/tsconfig.json @@ -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", + ] +} diff --git a/packages/kbn-management/settings/components/field_row/types.ts b/packages/kbn-management/settings/components/field_row/types.ts new file mode 100644 index 0000000000000..9eec1eb234f2c --- /dev/null +++ b/packages/kbn-management/settings/components/field_row/types.ts @@ -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 = (id: string, change: UnsavedFieldChange) => void; diff --git a/packages/kbn-management/settings/field_definition/README.mdx b/packages/kbn-management/settings/field_definition/README.mdx new file mode 100644 index 0000000000000..c26b5d850358c --- /dev/null +++ b/packages/kbn-management/settings/field_definition/README.mdx @@ -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. diff --git a/packages/kbn-management/settings/field_definition/get_definition.ts b/packages/kbn-management/settings/field_definition/get_definition.ts new file mode 100644 index 0000000000000..e6b29e6f437ca --- /dev/null +++ b/packages/kbn-management/settings/field_definition/get_definition.ts @@ -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 { + /** The id of the field. */ + id: string; + /** The source setting from Kibana. */ + setting: UiSettingMetadata; + /** 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 = ( + parameters: GetDefinitionParams +): FieldDefinition => { + 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 = { + 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; +}; diff --git a/packages/kbn-management/settings/field_definition/index.ts b/packages/kbn-management/settings/field_definition/index.ts new file mode 100644 index 0000000000000..2cd44db7df3b4 --- /dev/null +++ b/packages/kbn-management/settings/field_definition/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/field_definition/is/field_definition.ts b/packages/kbn-management/settings/field_definition/is/field_definition.ts new file mode 100644 index 0000000000000..52c6e83468177 --- /dev/null +++ b/packages/kbn-management/settings/field_definition/is/field_definition.ts @@ -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, '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'; diff --git a/packages/kbn-management/settings/field_definition/is/index.ts b/packages/kbn-management/settings/field_definition/is/index.ts new file mode 100644 index 0000000000000..ad5eb46cd3f53 --- /dev/null +++ b/packages/kbn-management/settings/field_definition/is/index.ts @@ -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'; diff --git a/packages/kbn-management/settings/field_definition/is/unsaved_change.ts b/packages/kbn-management/settings/field_definition/is/unsaved_change.ts new file mode 100644 index 0000000000000..6af63db17e36a --- /dev/null +++ b/packages/kbn-management/settings/field_definition/is/unsaved_change.ts @@ -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; + +/** + * 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'; diff --git a/packages/kbn-management/settings/field_definition/kibana.jsonc b/packages/kbn-management/settings/field_definition/kibana.jsonc new file mode 100644 index 0000000000000..687f04662bbe4 --- /dev/null +++ b/packages/kbn-management/settings/field_definition/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/management-settings-field-definition", + "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" +} diff --git a/packages/kbn-management/settings/field_definition/package.json b/packages/kbn-management/settings/field_definition/package.json new file mode 100644 index 0000000000000..63a4f90a3ee16 --- /dev/null +++ b/packages/kbn-management/settings/field_definition/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/field_definition/storybook/field_definition.ts b/packages/kbn-management/settings/field_definition/storybook/field_definition.ts new file mode 100644 index 0000000000000..022b2e3e98050 --- /dev/null +++ b/packages/kbn-management/settings/field_definition/storybook/field_definition.ts @@ -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 = ( + setting: UiSettingMetadata +): UiSettingMetadata => { + const { type } = setting; + return { + ...setting, + category: ['categoryOne', 'categoryTwo'], + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec eu odio velit. Integer et mauris quis ligula elementum commodo. Morbi eu ipsum diam. Nulla auctor orci eget egestas vehicula. Aliquam gravida, dolor eu posuere vulputate, neque enim viverra odio, id viverra ipsum quam et ipsum.', + name: `Some ${type} setting`, + requiresPageReload: false, + }; +}; + +interface OnChangeParams { + value?: KnownTypeToValue | null; + isInvalid?: boolean; + error?: string; +} + +type OnChangeFn = (params: OnChangeParams | null) => void; + +/** + * Hook to build and maintain a {@link FieldDefinition} for a given {@link UiSettingMetadata} object + * for use in Storybook. It provides the {@link FieldDefinition}, a stateful + * {@link UnsavedFieldChange} object, and an {@link OnChangeFn} to update the unsaved change based + * on the action taken within a {@link FieldInput} or {@link FieldRow}. + */ +export const useFieldDefinition = ( + baseSetting: UiSettingMetadata, + params: { isCustom?: boolean; isOverridden?: boolean; isDeprecated?: boolean } = {} +): [FieldDefinition, UnsavedFieldChange, OnChangeFn] => { + const setting = { + ...expandSetting(baseSetting), + deprecation: params.isDeprecated + ? { message: 'This setting is deprecated', docLinksKey: 'storybook' } + : undefined, + }; + + const field = getFieldDefinition({ + id: setting.name?.split(' ').join(':').toLowerCase() || setting.type, + setting, + params, + }); + + const { type, savedValue } = field; + + const [unsavedChange, setUnsavedChange] = useState>({ type }); + + const onChange: OnChangeFn = (change) => { + if (!change) { + return; + } + + const { value, error, isInvalid } = change; + + if (isEqual(value, savedValue)) { + setUnsavedChange({ type }); + } else { + setUnsavedChange({ type, unsavedValue: value, error, isInvalid }); + } + + const formattedSavedValue = type === 'image' ? String(savedValue).slice(0, 25) : savedValue; + const formattedUnsavedValue = type === 'image' ? String(value).slice(0, 25) : value; + + action('onChange')({ + type, + unsavedValue: formattedUnsavedValue, + savedValue: formattedSavedValue, + }); + }; + + return [field, unsavedChange, onChange]; +}; diff --git a/packages/kbn-management/settings/field_definition/storybook/index.ts b/packages/kbn-management/settings/field_definition/storybook/index.ts new file mode 100644 index 0000000000000..b372e1db1cf1b --- /dev/null +++ b/packages/kbn-management/settings/field_definition/storybook/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { getDefaultValue, getUserValue, IMAGE } from './values'; +export { useFieldDefinition } from './field_definition'; diff --git a/packages/kbn-management/settings/field_definition/storybook/values.ts b/packages/kbn-management/settings/field_definition/storybook/values.ts new file mode 100644 index 0000000000000..875f3eb11205a --- /dev/null +++ b/packages/kbn-management/settings/field_definition/storybook/values.ts @@ -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'; + } +}; diff --git a/packages/kbn-management/settings/field_definition/tsconfig.json b/packages/kbn-management/settings/field_definition/tsconfig.json new file mode 100644 index 0000000000000..4b85716365f5a --- /dev/null +++ b/packages/kbn-management/settings/field_definition/tsconfig.json @@ -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", + ] +} diff --git a/packages/kbn-management/settings/section_registry/jest.config.js b/packages/kbn-management/settings/jest.config.js similarity index 55% rename from packages/kbn-management/settings/section_registry/jest.config.js rename to packages/kbn-management/settings/jest.config.js index f183446f77bc6..f9df4c078fa83 100644 --- a/packages/kbn-management/settings/section_registry/jest.config.js +++ b/packages/kbn-management/settings/jest.config.js @@ -8,12 +8,10 @@ module.exports = { preset: '@kbn/test', - rootDir: '../../../..', - roots: ['/packages/kbn-management/settings/section_registry'], - coverageDirectory: - '/target/kibana-coverage/jest/packages/kbn-management/settings/section_registry', + rootDir: '../../..', + roots: ['/packages/kbn-management/settings'], + coverageDirectory: '/target/kibana-coverage/jest/packages/kbn-management/settings', coverageReporters: ['text', 'html'], - collectCoverageFrom: [ - '/packages/kbn-management/settings/section_registry/**/*.{ts,tsx}', - ], + collectCoverageFrom: ['/packages/kbn-management/settings/**/*.{ts,tsx}'], + coveragePathIgnorePatterns: ['__stories__', '.stories.tsx', 'storybook', 'mocks'], }; diff --git a/packages/kbn-management/settings/types/README.mdx b/packages/kbn-management/settings/types/README.mdx new file mode 100644 index 0000000000000..be258389beefe --- /dev/null +++ b/packages/kbn-management/settings/types/README.mdx @@ -0,0 +1,12 @@ +--- +id: management/settings/types +slug: /management/settings/types +title: Management Settings Typescript Types +description: Common types for objects and functions for Advanced Settings in Stack Management. +tags: ['management', 'settings'] +date: 2023-08-31 +--- + +## Description + +This package contains common types used throughout the `@kbn/management-settings-*` packages. diff --git a/packages/kbn-management/settings/types/field_definition.ts b/packages/kbn-management/settings/types/field_definition.ts new file mode 100644 index 0000000000000..eb34df3b67868 --- /dev/null +++ b/packages/kbn-management/settings/types/field_definition.ts @@ -0,0 +1,155 @@ +/* + * 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 { ReactElement } from 'react'; + +import { UiCounterMetricType } from '@kbn/analytics'; +import { DeprecationSettings } from '@kbn/core-ui-settings-common'; + +import { KnownTypeToValue, SettingType } from './setting_type'; + +/** + * A {@link FieldDefinition} adapts a {@link UiSettingMetadata} object to be more + * easily consumed by the UI. It contains additional information about the field + * that is determined from a given UiSettingMetadata object, (which is a type + * representing a UiSetting). + * @public + */ +export interface FieldDefinition | null> { + /** UX ARIA attributes derived from the setting. */ + ariaAttributes: { + /** The `aria-label` attribute for the field input. */ + ariaLabel: string; + /** The `aria-describedby` attribute for the field input. */ + ariaDescribedBy?: string; + }; + /** A list of categories related to the field. */ + categories: string[]; + /** The default value of the field from Kibana. */ + defaultValue?: V; + /** The text-based display of the default value, for use in the UI. */ + defaultValueDisplay: string; + /** + * Deprecation information for the field + * @see {@link DeprecationSettings} + */ + deprecation?: DeprecationSettings; + /** A description of the field. */ + description?: string | ReactElement; + /** The name of the field suitable for display in the UX. */ + displayName: string; + /** The grouping identifier for the field. */ + groupId: string; + /** The unique identifier of the field, typically separated by `:` */ + id: string; + /** True if the field is a custom setting, false otherwise. */ + isCustom: boolean; + /** True if the current saved setting matches the default setting. */ + isDefaultValue: boolean; + /** True if the setting is overridden in Kibana, false otherwise. */ + isOverridden: boolean; + /** True if the setting is read-only, false otherwise. */ + isReadOnly: boolean; + /** Metric information when one interacts with the field. */ + metric?: { + /** The metric name. */ + name?: string; + /** The metric type. */ + type?: UiCounterMetricType; + }; + /** The name of the field suitable for use in the UX. */ + name: string; + /** Option information if the field represents a `select` setting. */ + options?: { + /** Option values for the field. */ + values: string[] | number[]; + /** Option labels organized by value. */ + labels: Record; + }; + /** A rank order for the field relative to other fields. */ + order: number | undefined; + /** True if the browser must be reloaded for the setting to take effect, false otherwise. */ + requiresPageReload: boolean; + /** The current saved value of the setting. */ + savedValue?: V; + /** + * The type of setting the field represents. + * @see {@link SettingType} + */ + type: T; + /** An identifier of the field when it has an unsaved change. */ + unsavedFieldId: string; +} + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `array` type + * for use in the UI. + */ +export type ArrayFieldDefinition = FieldDefinition<'array'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `boolean` type + * for use in the UI. + */ +export type BooleanFieldDefinition = FieldDefinition<'boolean'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `color` type + * for use in the UI. + */ +export type ColorFieldDefinition = FieldDefinition<'color'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `image` type + * for use in the UI. + */ +export type ImageFieldDefinition = FieldDefinition<'image'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `json` type + * for use in the UI. + */ +export type JsonFieldDefinition = FieldDefinition<'json'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `markdown` type + * for use in the UI. + */ +export type MarkdownFieldDefinition = FieldDefinition<'markdown'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `number` type + * for use in the UI. + */ +export type NumberFieldDefinition = FieldDefinition<'number'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `select` type + * for use in the UI. + */ +export interface SelectFieldDefinition extends FieldDefinition<'select'> { + /** Options are required when this definition is used. */ + options: { + /** Option values for the field. */ + values: string[] | number[]; + /** Option labels organized by value. */ + labels: Record; + }; +} + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `string` type + * for use in the UI. + */ +export type StringFieldDefinition = FieldDefinition<'string'>; + +/** + * This is a {@link FieldDefinition} representing {@link UiSetting} `undefined` type + * for use in the UI. + */ +export type UndefinedFieldDefinition = FieldDefinition<'undefined'>; diff --git a/packages/kbn-management/settings/types/index.ts b/packages/kbn-management/settings/types/index.ts new file mode 100644 index 0000000000000..cc4d1738997a6 --- /dev/null +++ b/packages/kbn-management/settings/types/index.ts @@ -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. + */ + +export type { + ArrayFieldDefinition, + BooleanFieldDefinition, + ColorFieldDefinition, + ImageFieldDefinition, + JsonFieldDefinition, + FieldDefinition, + MarkdownFieldDefinition, + NumberFieldDefinition, + SelectFieldDefinition, + StringFieldDefinition, + UndefinedFieldDefinition, +} from './field_definition'; + +export type { + ArrayUiSettingMetadata, + BooleanUiSettingMetadata, + ColorUiSettingMetadata, + ImageUiSettingMetadata, + JsonUiSettingMetadata, + MarkdownUiSettingMetadata, + NumberUiSettingMetadata, + SelectUiSettingMetadata, + StringUiSettingMetadata, + UndefinedUiSettingMetadata, + UiSettingMetadata, + KnownTypeToMetadata, +} from './metadata'; + +export type { + ArrayUnsavedFieldChange, + BooleanUnsavedFieldChange, + ColorUnsavedFieldChange, + ImageUnsavedFieldChange, + JsonUnsavedFieldChange, + MarkdownUnsavedFieldChange, + NumberUnsavedFieldChange, + SelectUnsavedFieldChange, + StringUnsavedFieldChange, + UndefinedUnsavedFieldChange, + UnsavedFieldChange, +} from './unsaved_change'; + +export type { + ArraySettingType, + BooleanSettingType, + KnownTypeToValue, + NumberSettingType, + SettingType, + StringSettingType, + UndefinedSettingType, + Value, +} from './setting_type'; diff --git a/packages/kbn-management/settings/types/kibana.jsonc b/packages/kbn-management/settings/types/kibana.jsonc new file mode 100644 index 0000000000000..9482b2bb0f15f --- /dev/null +++ b/packages/kbn-management/settings/types/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/management-settings-types", + "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" +} diff --git a/packages/kbn-management/settings/types/metadata.ts b/packages/kbn-management/settings/types/metadata.ts new file mode 100644 index 0000000000000..8e191310b943d --- /dev/null +++ b/packages/kbn-management/settings/types/metadata.ts @@ -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. + */ + +import { PublicUiSettingsParams, UserProvidedValues } from '@kbn/core/public'; +import { KnownTypeToValue, SettingType } from './setting_type'; + +/** + * Creating this type based on {@link UiSettingsClientCommon} and exporting for ease. + */ +type UiSetting = PublicUiSettingsParams & UserProvidedValues; + +/** + * This is an type-safe abstraction over the {@link UiSetting} type, whose fields + * are not only optional, but also not strongly typed to + * {@link @kbn/core-ui-settings-common#UiSettingsType}. + * + * @public + */ +export interface UiSettingMetadata | null> + extends UiSetting { + /** + * The type of setting being represented. + * @see{@link SettingType} + */ + type: T; + /** The default value in Kibana for the setting. */ + value?: V; + /** The value saved by the user. */ + userValue?: V; +} + +/** + * This is an type-safe abstraction over the {@link UiSetting} `array` type. + * @public + */ +export type ArrayUiSettingMetadata = UiSettingMetadata<'array'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `boolean` type. + * @public + */ +export type BooleanUiSettingMetadata = UiSettingMetadata<'boolean'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `color` type. + * @public + */ +export type ColorUiSettingMetadata = UiSettingMetadata<'color'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `image` type. + * @public + */ +export type ImageUiSettingMetadata = UiSettingMetadata<'image'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `json` type. + * @public + */ +export type JsonUiSettingMetadata = UiSettingMetadata<'json'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `markdown` type. + * @public + */ +export type MarkdownUiSettingMetadata = UiSettingMetadata<'markdown'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `number` type. + * @public + */ +export type NumberUiSettingMetadata = UiSettingMetadata<'number'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `select` type. + * @public + */ +export type SelectUiSettingMetadata = UiSettingMetadata<'select'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `string` type. + * @public + */ +export type StringUiSettingMetadata = UiSettingMetadata<'string'>; + +/** + * This is an type-safe abstraction over the {@link UiSetting} `undefined` type. + * @public + */ +export type UndefinedUiSettingMetadata = UiSettingMetadata<'undefined'>; + +// prettier-ignore +/** + * This is a narrowing type, which finds the correct {@link UiSettingMetadata} + * type based on a given {@link SettingType}. + * @public + */ +export type KnownTypeToMetadata = + T extends 'array' ? ArrayUiSettingMetadata + : T extends 'boolean' ? BooleanUiSettingMetadata + : T extends 'color' ? ColorUiSettingMetadata + : T extends 'image' ? ImageUiSettingMetadata + : T extends 'json' ? JsonUiSettingMetadata + : T extends 'markdown' ? MarkdownUiSettingMetadata + : T extends 'number' ? NumberUiSettingMetadata + : T extends 'select' ? SelectUiSettingMetadata + : T extends 'string' ? StringUiSettingMetadata + : T extends 'undefined' ? UndefinedUiSettingMetadata + : never; diff --git a/packages/kbn-management/settings/types/package.json b/packages/kbn-management/settings/types/package.json new file mode 100644 index 0000000000000..43ed71ecaca83 --- /dev/null +++ b/packages/kbn-management/settings/types/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/management-settings-types", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/types/setting_type.ts b/packages/kbn-management/settings/types/setting_type.ts new file mode 100644 index 0000000000000..da297c6d94171 --- /dev/null +++ b/packages/kbn-management/settings/types/setting_type.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UiSettingsType } from '@kbn/core-ui-settings-common'; + +/** + * This is a local type equivalent to {@link UiSettingsType} for flexibility. + * @public + */ +export type SettingType = UiSettingsType; + +/** + * A narrowing type representing all {@link SettingType} values that correspond + * to an `array` primitive type value. + * @public + */ +export type ArraySettingType = Extract; + +/** + * A narrowing type representing all {@link SettingType} values that correspond + * to an `boolean` primitive type value. + * @public + */ +export type BooleanSettingType = Extract; + +/** + * A narrowing type representing all {@link SettingType} values that correspond + * to an `number` primitive type value. + * @public + */ +export type NumberSettingType = Extract; + +/** + * A narrowing type representing all {@link SettingType} values that correspond + * to an `string` primitive type value. + * @public + */ +export type StringSettingType = Extract< + SettingType, + 'color' | 'image' | 'json' | 'markdown' | 'select' | 'string' +>; + +/** + * A narrowing type representing all {@link SettingType} values that correspond + * to an `undefined` type value. + * @public + */ +export type UndefinedSettingType = Extract; + +/** + * A type representing all possible values corresponding to a given {@link SettingType}. + */ +export type Value = string | boolean | number | Array | undefined | null; + +// prettier-ignore +/** + * This is a narrowing type, which finds the correct primitive type based on a + * given {@link SettingType}. + * @public + */ +export type KnownTypeToValue = + T extends 'color' | 'image' | 'json' | 'markdown' | 'select' | 'string' ? string : + T extends 'boolean' ? boolean : + T extends 'number' | 'bigint' ? number : + T extends 'array' ? Array : + T extends 'undefined' ? undefined: + never; diff --git a/packages/kbn-management/settings/types/tsconfig.json b/packages/kbn-management/settings/types/tsconfig.json new file mode 100644 index 0000000000000..345fbe3125a79 --- /dev/null +++ b/packages/kbn-management/settings/types/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/analytics", + "@kbn/core", + "@kbn/core-ui-settings-common", + ] +} diff --git a/packages/kbn-management/settings/types/unsaved_change.ts b/packages/kbn-management/settings/types/unsaved_change.ts new file mode 100644 index 0000000000000..3bd815187f70a --- /dev/null +++ b/packages/kbn-management/settings/types/unsaved_change.ts @@ -0,0 +1,127 @@ +/* + * 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 { KnownTypeToValue, SettingType } from './setting_type'; + +/** + * A {@link UnsavedFieldChange} represents local changes to a field that have not + * yet been saved. + * @public + */ +export interface UnsavedFieldChange { + /** + * The type of setting. + * @see {@link SettingType} + */ + type: T; + /** An error message, if any, from the change. */ + error?: string | null; + /** True if the change is invalid for the field, false otherwise. */ + isInvalid?: boolean; + /** The current unsaved value stored in the field. */ + unsavedValue?: KnownTypeToValue | null; +} + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `number` value + * for use in the UI. + * @public + */ +export type ArrayUnsavedFieldChange = UnsavedFieldChange<'array'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `boolean` value + * for use in the UI. + * @public + */ +export type BooleanUnsavedFieldChange = UnsavedFieldChange<'boolean'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `color` value + * for use in the UI. + * @public + */ +export type ColorUnsavedFieldChange = UnsavedFieldChange<'color'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `image` value + * for use in the UI. + * @public + */ +export type ImageUnsavedFieldChange = UnsavedFieldChange<'image'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `json` value + * for use in the UI. + * @public + */ +export type JsonUnsavedFieldChange = UnsavedFieldChange<'json'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `markdown` value + * for use in the UI. + * @public + */ +export type MarkdownUnsavedFieldChange = UnsavedFieldChange<'markdown'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `number` value + * for use in the UI. + * @public + */ +export type NumberUnsavedFieldChange = UnsavedFieldChange<'number'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `select` value + * for use in the UI. + * @public + */ +export type SelectUnsavedFieldChange = UnsavedFieldChange<'select'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `string` value + * for use in the UI. + * @public + */ +export type StringUnsavedFieldChange = UnsavedFieldChange<'string'>; + +/** + * This is a {@link UnsavedFieldChange} representing an unsaved change to a + * {@link FieldDefinition} which has a {@link UiSetting} `undefined` value + * for use in the UI. + * @public + */ +export type UndefinedUnsavedFieldChange = UnsavedFieldChange<'undefined'>; + +// prettier-ignore +/** + * This is a narrowing type, which finds the correct primitive type based on a + * given {@link SettingType}. + * @public + */ +export type KnownTypeToUnsavedChange = + T extends 'array' ? ArrayUnsavedFieldChange : + T extends 'boolean' ? BooleanUnsavedFieldChange : + T extends 'color' ? ColorUnsavedFieldChange : + T extends 'image' ? ImageUnsavedFieldChange : + T extends 'json' ? JsonUnsavedFieldChange : + T extends 'markdown' ? MarkdownUnsavedFieldChange : + T extends 'number' | 'bigint' ? NumberUnsavedFieldChange : + T extends 'select' ? SelectUnsavedFieldChange : + T extends 'string' ? StringUnsavedFieldChange: + T extends 'undefined' ? UndefinedUnsavedFieldChange : + never; diff --git a/packages/kbn-management/settings/utilities/README.mdx b/packages/kbn-management/settings/utilities/README.mdx new file mode 100644 index 0000000000000..ef147d2fac252 --- /dev/null +++ b/packages/kbn-management/settings/utilities/README.mdx @@ -0,0 +1,12 @@ +--- +id: management/settings/utilities +slug: /management/settings/utilities +title: Management Settings Utilities +description: Utilities for working with Advanced Settings in Stack Management. +tags: ['management', 'settings'] +date: 2023-08-31 +--- + +## Description + +This package contains common utility functions for working with Advanced Settings in Stack Management. diff --git a/packages/kbn-management/settings/utilities/get_input_value.ts b/packages/kbn-management/settings/utilities/get_input_value.ts new file mode 100644 index 0000000000000..17ae6833fdb81 --- /dev/null +++ b/packages/kbn-management/settings/utilities/get_input_value.ts @@ -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 { SettingType, UnsavedFieldChange, FieldDefinition } from '@kbn/management-settings-types'; +import { hasUnsavedChange } from './has_unsaved_change'; + +type F = Pick, 'savedValue' | 'defaultValue'>; +type C = UnsavedFieldChange; + +/** + * Convenience function that, given a {@link FieldDefinition} and an {@link UnsavedFieldChange}, + * returns the value to be displayed in the input field, and a boolean indicating whether the + * value is an unsaved value. + * + * @param field The field to compare. + * @param change The unsaved change to compare. + */ +export function getInputValue(field: F<'array'>, change: C<'array'>): [string[], boolean]; +export function getInputValue(field: F<'color'>, change: C<'color'>): [string, boolean]; +export function getInputValue(field: F<'boolean'>, change: C<'boolean'>): [boolean, boolean]; +export function getInputValue(field: F<'image'>, change: C<'image'>): [string, boolean]; +export function getInputValue(field: F<'json'>, change: C<'json'>): [string, boolean]; +export function getInputValue(field: F<'markdown'>, change: C<'markdown'>): [string, boolean]; +export function getInputValue(field: F<'number'>, change: C<'number'>): [number, boolean]; +export function getInputValue(field: F<'select'>, change: C<'select'>): [string, boolean]; +export function getInputValue(field: F<'string'>, change: C<'string'>): [string, boolean]; +export function getInputValue( + field: F<'undefined'>, + change: C<'undefined'> +): [string | null | undefined, boolean]; +export function getInputValue(field: F, change: C) { + const isUnsavedValue = hasUnsavedChange(field, change); + + const value = isUnsavedValue + ? change.unsavedValue + : field.savedValue !== undefined && field.savedValue !== null + ? field.savedValue + : field.defaultValue; + + return [value, isUnsavedValue]; +} diff --git a/packages/kbn-management/settings/utilities/has_unsaved_change.ts b/packages/kbn-management/settings/utilities/has_unsaved_change.ts new file mode 100644 index 0000000000000..0ac783b439e4a --- /dev/null +++ b/packages/kbn-management/settings/utilities/has_unsaved_change.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import isEqual from 'lodash/isEqual'; + +import type { + FieldDefinition, + SettingType, + UnsavedFieldChange, +} from '@kbn/management-settings-types'; + +/** + * Compares a given {@link FieldDefinition} to an {@link UnsavedFieldChange} to determine + * if the field has an unsaved change in the UI. + * + * @param field The field to compare. + * @param unsavedChange The unsaved change to compare. + */ +export const hasUnsavedChange = ( + field: Pick, 'savedValue'>, + unsavedChange?: Pick, 'unsavedValue'> +) => { + if (!unsavedChange) { + return false; + } + + const { unsavedValue } = unsavedChange; + const { savedValue } = field; + return unsavedValue !== undefined && !isEqual(unsavedValue, savedValue); +}; diff --git a/packages/kbn-management/settings/utilities/index.ts b/packages/kbn-management/settings/utilities/index.ts new file mode 100644 index 0000000000000..1c35af180866d --- /dev/null +++ b/packages/kbn-management/settings/utilities/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { hasUnsavedChange } from './has_unsaved_change'; +export { isUnsavedValue } from './is_unsaved_value'; +export { getInputValue } from './get_input_value'; diff --git a/packages/kbn-management/settings/utilities/is_unsaved_value.ts b/packages/kbn-management/settings/utilities/is_unsaved_value.ts new file mode 100644 index 0000000000000..863d6c8b59ba0 --- /dev/null +++ b/packages/kbn-management/settings/utilities/is_unsaved_value.ts @@ -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 isEqual from 'lodash/isEqual'; + +import { FieldDefinition, KnownTypeToValue, SettingType } from '@kbn/management-settings-types'; + +/** + * Convenience function to compare a given {@link FieldDefinition} to an {@link UnsavedFieldChange} + * to determine if the value in the unsaved change is a different value from what is saved. + * + * @param field The field to compare. + * @param unsavedValue The unsaved value to compare. + */ +export const isUnsavedValue = ( + field: FieldDefinition, + unsavedValue?: KnownTypeToValue | null +) => { + const { savedValue } = field; + + return unsavedValue !== undefined && !isEqual(unsavedValue, savedValue); +}; diff --git a/packages/kbn-management/settings/utilities/kibana.jsonc b/packages/kbn-management/settings/utilities/kibana.jsonc new file mode 100644 index 0000000000000..391d209e9f192 --- /dev/null +++ b/packages/kbn-management/settings/utilities/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/management-settings-utilities", + "owner": "@elastic/platform-deployment-management @elastic/appex-sharedux" +} diff --git a/packages/kbn-management/settings/utilities/package.json b/packages/kbn-management/settings/utilities/package.json new file mode 100644 index 0000000000000..b82429aa30707 --- /dev/null +++ b/packages/kbn-management/settings/utilities/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/management-settings-utilities", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-management/settings/utilities/tsconfig.json b/packages/kbn-management/settings/utilities/tsconfig.json new file mode 100644 index 0000000000000..1247d2cd18707 --- /dev/null +++ b/packages/kbn-management/settings/utilities/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/management-settings-types", + ] +} diff --git a/packages/kbn-management/storybook/config/tsconfig.json b/packages/kbn-management/storybook/config/tsconfig.json index 52ae9f82c90f6..d383f3b0ba61e 100644 --- a/packages/kbn-management/storybook/config/tsconfig.json +++ b/packages/kbn-management/storybook/config/tsconfig.json @@ -4,7 +4,10 @@ "outDir": "target/types", "types": [ "jest", - "node" + "node", + "@kbn/ambient-ui-types", + "@kbn/ambient-storybook-types", + "@emotion/react/types/css-prop" ] }, "include": [ diff --git a/packages/kbn-storybook/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs index 21e1035627aeb..bf40dfb9fd3ca 100644 --- a/packages/kbn-storybook/templates/index.ejs +++ b/packages/kbn-storybook/templates/index.ejs @@ -74,4 +74,4 @@ <% }); %> - + \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 1459ef0fc229e..ff1ec70221c12 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -954,10 +954,20 @@ "@kbn/management-cards-navigation/*": ["packages/kbn-management/cards_navigation/*"], "@kbn/management-plugin": ["src/plugins/management"], "@kbn/management-plugin/*": ["src/plugins/management/*"], + "@kbn/management-settings-components-field-input": ["packages/kbn-management/settings/components/field_input"], + "@kbn/management-settings-components-field-input/*": ["packages/kbn-management/settings/components/field_input/*"], + "@kbn/management-settings-components-field-row": ["packages/kbn-management/settings/components/field_row"], + "@kbn/management-settings-components-field-row/*": ["packages/kbn-management/settings/components/field_row/*"], + "@kbn/management-settings-field-definition": ["packages/kbn-management/settings/field_definition"], + "@kbn/management-settings-field-definition/*": ["packages/kbn-management/settings/field_definition/*"], "@kbn/management-settings-ids": ["packages/kbn-management/settings/setting_ids"], "@kbn/management-settings-ids/*": ["packages/kbn-management/settings/setting_ids/*"], "@kbn/management-settings-section-registry": ["packages/kbn-management/settings/section_registry"], "@kbn/management-settings-section-registry/*": ["packages/kbn-management/settings/section_registry/*"], + "@kbn/management-settings-types": ["packages/kbn-management/settings/types"], + "@kbn/management-settings-types/*": ["packages/kbn-management/settings/types/*"], + "@kbn/management-settings-utilities": ["packages/kbn-management/settings/utilities"], + "@kbn/management-settings-utilities/*": ["packages/kbn-management/settings/utilities/*"], "@kbn/management-storybook-config": ["packages/kbn-management/storybook/config"], "@kbn/management-storybook-config/*": ["packages/kbn-management/storybook/config/*"], "@kbn/management-test-plugin": ["test/plugin_functional/plugins/management_test_plugin"], diff --git a/yarn.lock b/yarn.lock index 51180726acd2e..cc64e6913d2ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4850,6 +4850,18 @@ version "0.0.0" uid "" +"@kbn/management-settings-components-field-input@link:packages/kbn-management/settings/components/field_input": + version "0.0.0" + uid "" + +"@kbn/management-settings-components-field-row@link:packages/kbn-management/settings/components/field_row": + version "0.0.0" + uid "" + +"@kbn/management-settings-field-definition@link:packages/kbn-management/settings/field_definition": + version "0.0.0" + uid "" + "@kbn/management-settings-ids@link:packages/kbn-management/settings/setting_ids": version "0.0.0" uid "" @@ -4858,6 +4870,14 @@ version "0.0.0" uid "" +"@kbn/management-settings-types@link:packages/kbn-management/settings/types": + version "0.0.0" + uid "" + +"@kbn/management-settings-utilities@link:packages/kbn-management/settings/utilities": + version "0.0.0" + uid "" + "@kbn/management-storybook-config@link:packages/kbn-management/storybook/config": version "0.0.0" uid ""