[feature] Implement Setting Categories in Advanced Settings in serverless (#167447)

## Summary

This PR follows #166460 by adding Category panels to the Form.

<img width="1807" alt="Screenshot 2023-09-27 at 3 36 16 PM"
src="2abe8cf5-5822-473f-affd-148fb7949316">

## Notes
This PR is divided into several commits, the first few being
prerequisite codemods. I recommend reviewing each commit separately, as
the codemods might obscure the actual component work.

- [e78586f - Make SettingType pre-defined to clean up
references](e78586fe44)
- This makes the `SettingType` optional, to clean up areas where the
generic need not be specific.
- [80a9988 - [codemod] Make onFieldChange and onInputChange more
distinct](80a9988516)
- The `onChange` handlers weren't very clear as you work your way up the
component tree. This makes the implementation and usage easier to
understand, (and easier to [replace with state
management](https://github.com/elastic/kibana/issues/166579)).
- [5d0beff - [fix] Fix logged errors in form
tests](5d0beff00c)
- This fixes some logged errors in the Form from `Monaco` and from some
missing `act` and `waitFor` calls.
This commit is contained in:
Clint Andrew Hall 2023-09-28 13:24:15 -04:00 committed by GitHub
parent 1a8c2f3958
commit 76b832e12e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 1061 additions and 367 deletions

1
.github/CODEOWNERS vendored
View file

@ -484,6 +484,7 @@ 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_category @elastic/platform-deployment-management
packages/kbn-management/settings/components/field_input @elastic/platform-deployment-management
packages/kbn-management/settings/components/field_row @elastic/platform-deployment-management
packages/kbn-management/settings/components/form @elastic/platform-deployment-management

View file

@ -506,6 +506,7 @@
"@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-category": "link:packages/kbn-management/settings/components/field_category",
"@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-components-form": "link:packages/kbn-management/settings/components/form",

View file

@ -4,7 +4,7 @@ These packages comprise the Management Advanced Settings application. The sourc
## Notes
**Be aware**: the functional flow logic we've adopted for these components is not one I would encourage, specifically, using "drilled" onChange handlers and utilizing a composing-component-based store. Ideally, we'd use a Redux store, or, at the very least, a React reducer.
**Be aware**: the functional flow logic we've adopted for these components is not one I would encourage, specifically, using "drilled" onFieldChange handlers and utilizing a composing-component-based store. Ideally, we'd use a Redux store, or, at the very least, a React reducer.
In the interest of time and compatibility, we've opted to use the pattern from the original components in `advancedSettings`. We plan to revisit the state management and prop-drilling when `advancedSettings` is refactored with these components.

View file

@ -0,0 +1,12 @@
---
id: management/settings/components/fieldCategory
slug: /management/settings/components/field-category
title: Management Settings Field Category Component
description: A package containing components for rendering field rows in collections organized by their category.
tags: ['management', 'settings']
date: 2023-10-25
---
## Description
This package contains a component for rendering field rows of `UiSetting` objects in collections organized by their category. It's used primarily by the `Form` component.

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { ComponentMeta, Story } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { FieldCategories as Component } from '../categories';
import { Params, useCategoryStory } from './use_category_story';
import { FieldCategoryProvider } from '../services';
export default {
title: 'Settings/Field Category/Categories',
description: '',
args: {
isFiltered: false,
isSavingEnabled: true,
},
argTypes: {
isFiltered: {
control: {
type: 'boolean',
},
},
isSavingEnabled: {
control: {
type: 'boolean',
},
},
},
parameters: {
backgrounds: {
default: 'ghost',
},
},
} as ComponentMeta<typeof Component>;
export const Categories: Story<Params> = (params) => {
const { onClearQuery, isSavingEnabled, onFieldChange, unsavedChanges, categorizedFields } =
useCategoryStory(params);
return (
<FieldCategoryProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
>
<Component
{...{ categorizedFields, onFieldChange, unsavedChanges, onClearQuery, isSavingEnabled }}
/>
</FieldCategoryProvider>
);
};

View file

@ -0,0 +1,83 @@
/*
* 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 { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock';
import { getFieldDefinitions } from '@kbn/management-settings-field-definition';
import { categorizeFields } from '@kbn/management-settings-utilities';
import { FieldRow } from '@kbn/management-settings-components-field-row';
import { FieldCategory as Component, type FieldCategoryProps as ComponentProps } from '../category';
import { Params, useCategoryStory } from './use_category_story';
import { FieldCategoryProvider } from '../services';
const settings = getSettingsMock();
// Markdown and JSON fields require Monaco, which are *notoriously* slow in Storybook due
// to the lack of a webworker. Until we can resolve it, filter out those fields.
const definitions = getFieldDefinitions(settings, {
isCustom: () => {
return false;
},
isOverridden: () => {
return false;
},
}).filter((field) => field.type !== 'json' && field.type !== 'markdown');
const categories = Object.keys(categorizeFields(definitions));
export default {
title: 'Settings/Field Category/Category',
description: '',
args: {
category: categories[0],
isFiltered: false,
isSavingEnabled: true,
},
argTypes: {
category: {
control: {
type: 'select',
options: categories,
},
},
},
} as ComponentMeta<typeof Component>;
type FieldCategoryParams = Pick<ComponentProps, 'category'> & Params;
export const Category = ({ isFiltered, category, isSavingEnabled }: FieldCategoryParams) => {
const { onClearQuery, onFieldChange, unsavedChanges } = useCategoryStory({
isFiltered,
isSavingEnabled,
});
const { count, fields } = categorizeFields(definitions)[category];
const rows = isFiltered ? [fields[0]] : fields;
return (
<FieldCategoryProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
{...{ isSavingEnabled, onFieldChange }}
>
<Component category={category} fieldCount={count} onClearQuery={onClearQuery}>
{rows.map((field) => (
<FieldRow
key={field.id}
unsavedChange={unsavedChanges[field.id]}
{...{ field, isSavingEnabled, onFieldChange }}
/>
))}
</Component>
</FieldCategoryProvider>
);
};

View file

@ -0,0 +1,63 @@
/*
* 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 { useArgs } from '@storybook/client-api';
import { action } from '@storybook/addon-actions';
import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock';
import { getFieldDefinitions } from '@kbn/management-settings-field-definition';
import { categorizeFields } from '@kbn/management-settings-utilities';
import { UnsavedFieldChanges, OnFieldChangeFn } from '@kbn/management-settings-types';
export interface Params {
isFiltered: boolean;
isSavingEnabled: boolean;
}
export const useCategoryStory = ({ isFiltered, isSavingEnabled }: Params) => {
const [_args, updateArgs] = useArgs();
const settings = getSettingsMock();
// Markdown and JSON fields require Monaco, which are *notoriously* slow in Storybook due
// to the lack of a webworker. Until we can resolve it, filter out those fields.
const definitions = getFieldDefinitions(settings, {
isCustom: () => {
return false;
},
isOverridden: () => {
return false;
},
}).filter((field) => field.type !== 'json' && field.type !== 'markdown');
const categorizedFields = categorizeFields(definitions);
if (isFiltered) {
Object.keys(categorizedFields).forEach((category) => {
categorizedFields[category].fields = categorizedFields[category].fields.slice(0, 1);
});
}
const onClearQuery = () => updateArgs({ isFiltered: false });
const [unsavedChanges, setUnsavedChanges] = React.useState<UnsavedFieldChanges>({});
const onFieldChange: OnFieldChangeFn = (id, change) => {
action('onFieldChange')(id, change);
if (!change) {
const { [id]: unsavedChange, ...rest } = unsavedChanges;
setUnsavedChanges(rest);
return;
}
setUnsavedChanges((changes) => ({ ...changes, [id]: change }));
};
return { onClearQuery, onFieldChange, isSavingEnabled, unsavedChanges, categorizedFields };
};

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { CategorizedFields, UnsavedFieldChanges } from '@kbn/management-settings-types';
import { FieldRow, FieldRowProps } from '@kbn/management-settings-components-field-row';
import { FieldCategory, type FieldCategoryProps } from './category';
/**
* Props for the {@link FieldCategories} component.
*/
export interface FieldCategoriesProps
extends Pick<FieldCategoryProps, 'onClearQuery'>,
Pick<FieldRowProps, 'onFieldChange' | 'isSavingEnabled'> {
/** Categorized fields for display. */
categorizedFields: CategorizedFields;
/** And unsaved changes currently managed by the parent component. */
unsavedChanges?: UnsavedFieldChanges;
}
/**
* Convenience component for displaying a set of {@link FieldCategory} components, given
* a set of categorized fields.
*
* @param {FieldCategoriesProps} props props to pass to the {@link FieldCategories} component.
*/
export const FieldCategories = ({
categorizedFields,
unsavedChanges = {},
onClearQuery,
isSavingEnabled,
onFieldChange,
}: FieldCategoriesProps) => (
<>
{Object.entries(categorizedFields).map(([category, { count, fields }]) => (
<FieldCategory key={category} fieldCount={count} {...{ category, onClearQuery }}>
{fields.map((field) => (
<FieldRow
key={field.id}
unsavedChange={unsavedChanges[field.id]}
{...{ field, isSavingEnabled, onFieldChange }}
/>
))}
</FieldCategory>
))}
</>
);

View file

@ -0,0 +1,67 @@
/*
* 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, Children } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSplitPanel, EuiTitle, useEuiTheme } from '@elastic/eui';
import { getCategoryName } from '@kbn/management-settings-utilities';
import type { FieldRowProps } from '@kbn/management-settings-components-field-row';
import { css } from '@emotion/react';
import { ClearQueryLink, ClearQueryLinkProps } from './clear_query_link';
/**
* Props for a {@link FieldCategory} component.
*/
export interface FieldCategoryProps
extends Pick<ClearQueryLinkProps, 'onClearQuery' | 'fieldCount'> {
/** The name of the category. */
category: string;
/** Children-- should be {@link FieldRow} components. */
children:
| ReactElement<FieldRowProps, 'FieldRow'>
| Array<ReactElement<FieldRowProps, 'FieldRow'>>;
}
/**
* Component for displaying a container of fields pertaining to a single
* category.
* @param props - the props to pass to the {@link FieldCategory} component.
*/
export const FieldCategory = (props: FieldCategoryProps) => {
const { category, fieldCount, onClearQuery, children } = props;
const {
euiTheme: { size },
} = useEuiTheme();
const displayCount = Children.count(children);
const panelCSS = css`
& + & {
margin-top: ${size.l};
}
`;
return (
<EuiSplitPanel.Outer hasBorder key={category} css={panelCSS}>
<EuiSplitPanel.Inner color="subdued">
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>{getCategoryName(category)}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<ClearQueryLink {...{ displayCount, fieldCount, onClearQuery }} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner>{children}</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
);
};

View file

@ -0,0 +1,59 @@
/*
* 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 { css } from '@emotion/react';
import { EuiLink, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
/**
* Props for the {@link ClearQueryLink} component.
*/
export interface ClearQueryLinkProps {
/** The total number of fields in the category. */
fieldCount: number;
/** The number of fields currently being displayed. */
displayCount: number;
/** Handler to invoke when clearing the current filtering query. */
onClearQuery: () => void;
}
/**
* Component for displaying a link to clear the current filtering query.
*/
export const ClearQueryLink = ({ fieldCount, displayCount, onClearQuery }: ClearQueryLinkProps) => {
if (fieldCount === displayCount) {
return null;
}
const linkCSS = css`
font-style: italic;
`;
return (
<EuiText css={linkCSS} size="s">
<FormattedMessage
id="management.settings.form.searchResultText"
defaultMessage="Search terms are hiding {settingsCount} settings {clearSearch}"
values={{
settingsCount: fieldCount - displayCount,
clearSearch: (
<EuiLink onClick={onClearQuery}>
<EuiText css={linkCSS} size="s">
<FormattedMessage
id="management.settings.form.clearSearchResultText"
defaultMessage="(clear search)"
/>
</EuiText>
</EuiLink>
),
}}
/>
</EuiText>
);
};

View file

@ -0,0 +1,17 @@
/*
* 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 { FieldCategories, type FieldCategoriesProps } from './categories';
export { FieldCategory, type FieldCategoryProps } from './category';
export type { ClearQueryLinkProps } from './clear_query_link';
export type { FieldCategoryKibanaDependencies, FieldCategoryServices } from './types';
export {
FieldCategoryKibanaProvider,
FieldCategoryProvider,
type FieldCategoryProviderProps,
} from './services';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/packages/kbn-management/settings/components/field_category'],
};

View file

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

View file

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

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import {
FieldRowProvider,
FieldRowKibanaProvider,
} from '@kbn/management-settings-components-field-row';
import type { FieldCategoryServices } from './types';
/**
* Props for {@link FieldCategoryProvider}.
*/
export interface FieldCategoryProviderProps extends FieldCategoryServices {
children: React.ReactNode;
}
/**
* React Provider that provides services to a {@link FieldCategory} component and its dependents.
*/
export const FieldCategoryProvider = FieldRowProvider;
/**
* Kibana-specific Provider that maps Kibana plugins and services to a {@link FieldCategoryProvider}.
*/
export const FieldCategoryKibanaProvider = FieldRowKibanaProvider;

View file

@ -0,0 +1,25 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/management-settings-utilities",
"@kbn/management-settings-field-definition",
"@kbn/management-settings-components-field-row",
"@kbn/management-settings-types",
"@kbn/i18n-react",
]
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type {
FieldRowServices,
FieldRowKibanaDependencies,
} from '@kbn/management-settings-components-field-row';
/**
* Contextual services used by a {@link FieldCategory} component and its dependents.
*/
export type FieldCategoryServices = FieldRowServices;
/**
* An interface containing a collection of Kibana plugins and services required to
* render a {@link FieldCategory} component and its dependents.
*/
export type FieldCategoryKibanaDependencies = FieldRowKibanaDependencies;

View file

@ -13,7 +13,7 @@ import { action } from '@storybook/addon-actions';
import { EuiPanel } from '@elastic/eui';
import { UiSettingsType } from '@kbn/core-ui-settings-common';
import {
OnChangeFn,
OnInputChangeFn,
SettingType,
UiSettingMetadata,
UnsavedFieldChange,
@ -108,17 +108,17 @@ export const getInputStory = (type: SettingType, params: Params = {}) => {
setting,
});
const onChange: OnChangeFn<typeof type> = (newChange) => {
const onInputChange: OnInputChangeFn<typeof type> = (newChange) => {
setUnsavedChange(newChange);
action('onChange')({
action('onInputChange')({
type,
unsavedValue: newChange?.unsavedValue,
savedValue: field.savedValue,
});
};
return <FieldInput {...{ field, unsavedChange, onChange, isSavingEnabled }} />;
return <FieldInput {...{ field, unsavedChange, onInputChange, isSavingEnabled }} />;
};
Story.argTypes = {

View file

@ -55,7 +55,7 @@ describe('FieldInput', () => {
},
options,
} as FieldDefinition<typeof type>,
onChange: jest.fn(),
onInputChange: jest.fn(),
isSavingEnabled: true,
};
@ -132,7 +132,7 @@ describe('FieldInput', () => {
const { getByTestId } = render(wrap(<FieldInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${name}`);
fireEvent.change(input, { target: { value: 'new value' } });
expect(props.onChange).toHaveBeenCalledWith({ type: 'string', unsavedValue: 'new value' });
expect(props.onInputChange).toHaveBeenCalledWith({ type: 'string', unsavedValue: 'new value' });
});
it('disables the input when isDisabled prop is true', () => {
@ -191,7 +191,7 @@ describe('FieldInput', () => {
...defaultProps.field,
type: 'foobar',
},
} as unknown as FieldInputProps<SettingType>;
} as unknown as FieldInputProps;
expect(() => render(wrap(<FieldInput {...props} />))).toThrowError(
'Unknown or incompatible field type: foobar'

View file

@ -10,7 +10,7 @@ import React, { useImperativeHandle, useRef } from 'react';
import type {
FieldDefinition,
OnChangeFn,
OnInputChangeFn,
ResetInputRef,
SettingType,
UnsavedFieldChange,
@ -56,13 +56,13 @@ import {
/**
* The props that are passed to the {@link FieldInput} component.
*/
export interface FieldInputProps<T extends SettingType> {
export interface FieldInputProps<T extends SettingType = SettingType> {
/** The {@link FieldDefinition} for the component. */
field: Pick<FieldDefinition<T>, 'type' | 'id' | 'name' | 'ariaAttributes'>;
/** An {@link UnsavedFieldChange} for the component, if any. */
unsavedChange?: UnsavedFieldChange<T>;
/** The `onChange` handler for the input. */
onChange: OnChangeFn<T>;
/** The `onInputChange` handler for the input. */
onInputChange: OnInputChangeFn<T>;
/** True if the input can be saved, false otherwise. */
isSavingEnabled: boolean;
/** True if the value within the input is invalid, false otherwise. */
@ -81,140 +81,136 @@ const getMismatchError = (type: SettingType, unsavedType?: SettingType) =>
*
* @param props The props for the {@link FieldInput} component.
*/
export const FieldInput = React.forwardRef<ResetInputRef, FieldInputProps<SettingType>>(
(props, ref) => {
const { field, unsavedChange, onChange, isSavingEnabled } = props;
export const FieldInput = React.forwardRef<ResetInputRef, FieldInputProps>((props, ref) => {
const { field, unsavedChange, onInputChange, isSavingEnabled } = props;
// Create a ref for those input fields that require an imperative handle.
const inputRef = useRef<ResetInputRef>(null);
// Create a ref for those input fields that require an imperative handle.
const inputRef = useRef<ResetInputRef>(null);
// Create an imperative handle that passes the invocation to any internal input that
// may require it.
useImperativeHandle(ref, () => ({
reset: () => {
if (inputRef.current) {
inputRef.current.reset();
}
},
}));
const inputProps = { isSavingEnabled, onChange };
// These checks might seem excessive or redundant, but they are necessary to ensure that
// the types are honored correctly using type guards. These checks get compiled down to
// checks against the `type` property-- which we were doing in the previous code, albeit
// in an unenforceable way.
//
// Based on the success of a check, we can render the `FieldInput` in a indempotent and
// type-safe way.
//
if (isArrayFieldDefinition(field)) {
// If the composing component mistakenly provides an incompatible `UnsavedFieldChange`,
// we can throw an `Error`. We might consider switching to a `console.error` and not
// rendering the input, but that might be less helpful.
if (!isArrayFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
// Create an imperative handle that passes the invocation to any internal input that
// may require it.
useImperativeHandle(ref, () => ({
reset: () => {
if (inputRef.current) {
inputRef.current.reset();
}
},
}));
return <ArrayInput {...{ field, unsavedChange, ...inputProps }} />;
const inputProps = { isSavingEnabled, onInputChange };
// 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);
}
if (isBooleanFieldDefinition(field)) {
if (!isBooleanFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <BooleanInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isColorFieldDefinition(field)) {
if (!isColorFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <ColorPickerInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isImageFieldDefinition(field)) {
if (!isImageFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <ImageInput {...{ field, unsavedChange, ...inputProps }} ref={inputRef} />;
}
if (isJsonFieldDefinition(field)) {
if (!isJsonFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<CodeEditorInput
{...{ field, unsavedChange, ...inputProps }}
type="json"
defaultValue={field.savedValue || ''}
/>
);
}
if (isMarkdownFieldDefinition(field)) {
if (!isMarkdownFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<CodeEditorInput
{...{ field, unsavedChange, ...inputProps }}
type="markdown"
defaultValue={field.savedValue || ''}
/>
);
}
if (isNumberFieldDefinition(field)) {
if (!isNumberFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <NumberInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isSelectFieldDefinition(field)) {
if (!isSelectFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const {
options: { values: optionValues, labels: optionLabels },
} = field;
return (
<SelectInput {...{ field, unsavedChange, optionLabels, optionValues, ...inputProps }} />
);
}
if (isStringFieldDefinition(field)) {
if (!isStringFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <TextInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isUndefinedFieldDefinition(field)) {
if (!isUndefinedFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<TextInput
field={field as unknown as FieldDefinition<'string'>}
unsavedChange={unsavedChange as unknown as UnsavedFieldChange<'string'>}
{...inputProps}
/>
);
}
throw new Error(`Unknown or incompatible field type: ${field.type}`);
return <ArrayInput {...{ field, unsavedChange, ...inputProps }} />;
}
);
if (isBooleanFieldDefinition(field)) {
if (!isBooleanFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <BooleanInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isColorFieldDefinition(field)) {
if (!isColorFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <ColorPickerInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isImageFieldDefinition(field)) {
if (!isImageFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <ImageInput {...{ field, unsavedChange, ...inputProps }} ref={inputRef} />;
}
if (isJsonFieldDefinition(field)) {
if (!isJsonFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<CodeEditorInput
{...{ field, unsavedChange, ...inputProps }}
type="json"
defaultValue={field.savedValue || ''}
/>
);
}
if (isMarkdownFieldDefinition(field)) {
if (!isMarkdownFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<CodeEditorInput
{...{ field, unsavedChange, ...inputProps }}
type="markdown"
defaultValue={field.savedValue || ''}
/>
);
}
if (isNumberFieldDefinition(field)) {
if (!isNumberFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <NumberInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isSelectFieldDefinition(field)) {
if (!isSelectFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
const {
options: { values: optionValues, labels: optionLabels },
} = field;
return <SelectInput {...{ field, unsavedChange, optionLabels, optionValues, ...inputProps }} />;
}
if (isStringFieldDefinition(field)) {
if (!isStringFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return <TextInput {...{ field, unsavedChange, ...inputProps }} />;
}
if (isUndefinedFieldDefinition(field)) {
if (!isUndefinedFieldUnsavedChange(unsavedChange)) {
throw getMismatchError(field.type, unsavedChange?.type);
}
return (
<TextInput
field={field as unknown as FieldDefinition<'string'>}
unsavedChange={unsavedChange as unknown as UnsavedFieldChange<'string'>}
{...inputProps}
/>
);
}
throw new Error(`Unknown or incompatible field type: ${field.type}`);
});

View file

@ -18,9 +18,9 @@ const name = 'Some array field';
const id = 'some:array:field';
describe('ArrayInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: InputProps<'array'> = {
onChange,
onInputChange,
field: {
name,
type: 'array',
@ -35,7 +35,7 @@ describe('ArrayInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -70,7 +70,7 @@ describe('ArrayInput', () => {
expect(input).toHaveValue('foo, bar, baz');
});
it('only calls onChange when blurred ', () => {
it('only calls onInputChange when blurred ', () => {
render(wrap(<ArrayInput {...defaultProps} />));
const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
@ -78,13 +78,13 @@ describe('ArrayInput', () => {
userEvent.type(input, ',baz');
expect(input).toHaveValue('foo, bar,baz');
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(defaultProps.onInputChange).not.toHaveBeenCalled();
act(() => {
input.blur();
});
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'array',
unsavedValue: ['foo', 'bar', 'baz'],
});

View file

@ -29,7 +29,7 @@ export const ArrayInput = ({
field,
unsavedChange,
isSavingEnabled,
onChange: onChangeProp,
onInputChange,
}: ArrayInputProps) => {
const [inputValue] = getFieldInputValue(field, unsavedChange) || [];
const [value, setValue] = useState(inputValue?.join(', '));
@ -39,7 +39,7 @@ export const ArrayInput = ({
setValue(newValue);
};
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const onUpdate = useUpdate({ onInputChange, field });
useEffect(() => {
setValue(inputValue?.join(', '));

View file

@ -19,9 +19,9 @@ const name = 'Some boolean field';
const id = 'some:boolean:field';
describe('BooleanInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: InputProps<'boolean'> = {
onChange,
onInputChange,
field: {
name,
type: 'boolean',
@ -36,7 +36,7 @@ describe('BooleanInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders false', () => {
@ -60,16 +60,16 @@ describe('BooleanInput', () => {
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toBeChecked();
});
it('calls onChange when toggled', () => {
it('calls onInputChange when toggled', () => {
render(wrap(<BooleanInput {...defaultProps} />));
const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(defaultProps.onInputChange).not.toHaveBeenCalled();
act(() => {
fireEvent.click(input);
});
expect(defaultProps.onChange).toBeCalledWith({ type: 'boolean', unsavedValue: true });
expect(defaultProps.onInputChange).toBeCalledWith({ type: 'boolean', unsavedValue: true });
act(() => {
fireEvent.click(input);

View file

@ -28,15 +28,15 @@ export const BooleanInput = ({
field,
unsavedChange,
isSavingEnabled,
onChange: onChangeProp,
onInputChange,
}: BooleanInputProps) => {
const onUpdate = useUpdate({ onInputChange, field });
const onChange: EuiSwitchProps['onChange'] = (event) => {
const inputValue = event.target.checked;
onUpdate({ type: field.type, unsavedValue: inputValue });
};
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const { id, name, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;
const [value] = getFieldInputValue(field, unsavedChange);

View file

@ -43,9 +43,9 @@ export const CodeEditorInput = ({
type,
isSavingEnabled,
defaultValue,
onChange: onChangeProp,
onInputChange,
}: CodeEditorInputProps) => {
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const onUpdate = useUpdate({ onInputChange, field });
const onChange: CodeEditorProps['onChange'] = (inputValue) => {
let newUnsavedValue;

View file

@ -15,9 +15,9 @@ const name = 'Some color field';
const id = 'some:color:field';
describe('ColorPickerInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: ColorPickerInputProps = {
onChange,
onInputChange,
field: {
name,
type: 'color',
@ -32,7 +32,7 @@ describe('ColorPickerInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -42,20 +42,23 @@ describe('ColorPickerInput', () => {
expect(input).toHaveValue('#000000');
});
it('calls the onChange prop when the value changes', () => {
it('calls the onInputChange prop when the value changes', () => {
const { getByRole } = render(wrap(<ColorPickerInput {...defaultProps} />));
const input = getByRole('textbox');
const newValue = '#ffffff';
fireEvent.change(input, { target: { value: newValue } });
expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'color', unsavedValue: newValue });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'color',
unsavedValue: newValue,
});
});
it('calls the onChange prop with an error when the value is malformed', () => {
it('calls the onInputChange prop with an error when the value is malformed', () => {
const { getByRole } = render(wrap(<ColorPickerInput {...defaultProps} />));
const input = getByRole('textbox');
const newValue = '#1234';
fireEvent.change(input, { target: { value: newValue } });
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'color',
unsavedValue: newValue,
isInvalid: true,

View file

@ -32,9 +32,9 @@ export const ColorPickerInput = ({
field,
unsavedChange,
isSavingEnabled,
onChange: onChangeProp,
onInputChange,
}: ColorPickerInputProps) => {
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const onUpdate = useUpdate({ onInputChange, field });
const onChange: EuiColorPickerProps['onChange'] = (newColor, { isValid }) => {
const update: UnsavedFieldChange<'color'> = { type: field.type, unsavedValue: newColor };

View file

@ -18,9 +18,9 @@ const name = 'Some image field';
const id = 'some:image:field';
describe('ImageInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: ImageInputProps = {
onChange,
onInputChange,
field: {
name,
type: 'image',
@ -35,7 +35,7 @@ describe('ImageInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -43,7 +43,7 @@ describe('ImageInput', () => {
expect(container).toBeInTheDocument();
});
it('calls the onChange prop when a file is selected', async () => {
it('calls the onInputChange prop when a file is selected', async () => {
const { getByTestId } = render(wrap(<ImageInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`) as HTMLInputElement;
const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
@ -55,7 +55,7 @@ describe('ImageInput', () => {
expect(input.files?.length).toBe(1);
// This doesn't work for some reason.
// expect(defaultProps.onChange).toHaveBeenCalledWith({ value: file });
// expect(defaultProps.onInputChange).toHaveBeenCalledWith({ value: file });
});
it('disables the input when isDisabled prop is true', () => {

View file

@ -44,7 +44,7 @@ const errorMessage = i18n.translate('management.settings.field.imageChangeErrorM
* Component for manipulating an `image` field.
*/
export const ImageInput = React.forwardRef<ResetInputRef, ImageInputProps>(
({ field, unsavedChange, isSavingEnabled, onChange: onChangeProp }, ref) => {
({ field, unsavedChange, isSavingEnabled, onInputChange }, ref) => {
const inputRef = useRef<EuiFilePicker>(null);
useImperativeHandle(ref, () => ({
@ -53,7 +53,7 @@ export const ImageInput = React.forwardRef<ResetInputRef, ImageInputProps>(
const { showDanger } = useServices();
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const onUpdate = useUpdate({ onInputChange, field });
const onChange: EuiFilePickerProps['onChange'] = async (files: FileList | null) => {
if (files === null || !files.length) {

View file

@ -33,9 +33,9 @@ jest.mock('../code_editor', () => ({
}));
describe('JsonEditorInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: CodeEditorInputProps = {
onChange,
onInputChange,
type: 'json',
field: {
name,
@ -51,7 +51,7 @@ describe('JsonEditorInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -65,28 +65,28 @@ describe('JsonEditorInput', () => {
expect(input).toHaveValue(initialValue);
});
it('calls the onChange prop when the object value changes', () => {
it('calls the onInputChange prop when the object value changes', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '{"bar":"foo"}' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '{"bar":"foo"}',
});
});
it('calls the onChange prop when the object value changes with no value', () => {
it('calls the onInputChange prop when the object value changes with no value', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' });
});
it('calls the onChange prop with an error when the object value changes to invalid JSON', () => {
it('calls the onInputChange prop with an error when the object value changes to invalid JSON', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '{"bar" "foo"}' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '{"bar" "foo"}',
error: 'Invalid JSON syntax',
@ -94,20 +94,20 @@ describe('JsonEditorInput', () => {
});
});
it('calls the onChange prop when the array value changes', () => {
it('calls the onInputChange prop when the array value changes', () => {
const props = { ...defaultProps, defaultValue: '["bar", "foo"]', value: undefined };
const { getByTestId } = render(<CodeEditorInput {...props} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '["foo", "bar", "baz"]' } });
waitFor(() =>
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '["foo", "bar", "baz"]',
})
);
});
it('calls the onChange prop when the array value changes with no value', () => {
it('calls the onInputChange prop when the array value changes with no value', () => {
const props = {
...defaultProps,
defaultValue: '["bar", "foo"]',
@ -116,15 +116,15 @@ describe('JsonEditorInput', () => {
const { getByTestId } = render(<CodeEditorInput {...props} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' });
});
it('calls the onChange prop with an array when the array value changes to invalid JSON', () => {
it('calls the onInputChange prop with an array when the array value changes to invalid JSON', () => {
const props = { ...defaultProps, defaultValue: '["bar", "foo"]', value: undefined };
const { getByTestId } = render(<CodeEditorInput {...props} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '["bar", "foo" | "baz"]' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '["bar", "foo" | "baz"]',
error: 'Invalid JSON syntax',

View file

@ -33,9 +33,9 @@ jest.mock('../code_editor', () => ({
}));
describe('MarkdownEditorInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: CodeEditorInputProps = {
onChange,
onInputChange,
type: 'markdown',
field: {
name,
@ -51,7 +51,7 @@ describe('MarkdownEditorInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -65,11 +65,11 @@ describe('MarkdownEditorInput', () => {
expect(input).toHaveValue(initialValue);
});
it('calls the onChange prop when the value changes', () => {
it('calls the onInputChange prop when the value changes', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '# New Markdown Title' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'markdown',
unsavedValue: '# New Markdown Title',
});

View file

@ -16,9 +16,9 @@ const name = 'Some number field';
const id = 'some:number:field';
describe('NumberInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: NumberInputProps = {
onChange,
onInputChange,
field: {
name,
type: 'number',
@ -33,7 +33,7 @@ describe('NumberInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -65,11 +65,14 @@ describe('NumberInput', () => {
expect(input).toHaveValue(4321);
});
it('calls the onChange prop when the value changes', () => {
it('calls the onInputChange prop when the value changes', () => {
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '54321' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'number', unsavedValue: 54321 });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'number',
unsavedValue: 54321,
});
});
it('disables the input when isDisabled prop is true', () => {

View file

@ -26,14 +26,14 @@ export const NumberInput = ({
field,
unsavedChange,
isSavingEnabled,
onChange: onChangeProp,
onInputChange,
}: NumberInputProps) => {
const onChange: EuiFieldNumberProps['onChange'] = (event) => {
const inputValue = Number(event.target.value);
onUpdate({ type: field.type, unsavedValue: inputValue });
};
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const onUpdate = useUpdate({ onInputChange, field });
const { id, name, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;

View file

@ -16,9 +16,9 @@ const name = 'Some select field';
const id = 'some:select:field';
describe('SelectInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: SelectInputProps = {
onChange,
onInputChange,
field: {
name,
type: 'select',
@ -39,7 +39,7 @@ describe('SelectInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -49,11 +49,14 @@ describe('SelectInput', () => {
expect(input).toHaveValue('option2');
});
it('calls the onChange prop when the value changes', () => {
it('calls the onInputChange prop when the value changes', () => {
const { getByTestId } = render(wrap(<SelectInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: 'option3' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({ type: 'select', unsavedValue: 'option3' });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'select',
unsavedValue: 'option3',
});
});
it('disables the input when isDisabled prop is true', () => {

View file

@ -30,7 +30,7 @@ export interface SelectInputProps extends InputProps<'select'> {
export const SelectInput = ({
field,
unsavedChange,
onChange: onChangeProp,
onInputChange,
optionLabels = {},
optionValues: optionsProp,
isSavingEnabled,
@ -53,7 +53,7 @@ export const SelectInput = ({
onUpdate({ type: field.type, unsavedValue: inputValue });
};
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const onUpdate = useUpdate({ onInputChange, field });
const { id, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;

View file

@ -16,9 +16,9 @@ const name = 'Some text field';
const id = 'some:text:field';
describe('TextInput', () => {
const onChange = jest.fn();
const onInputChange = jest.fn();
const defaultProps: TextInputProps = {
onChange,
onInputChange,
field: {
name,
type: 'string',
@ -33,7 +33,7 @@ describe('TextInput', () => {
};
beforeEach(() => {
onChange.mockClear();
onInputChange.mockClear();
});
it('renders without errors', () => {
@ -47,11 +47,11 @@ describe('TextInput', () => {
expect(input).toHaveValue('initial value');
});
it('calls the onChange prop when the value changes', () => {
it('calls the onInputChange prop when the value changes', () => {
const { getByTestId } = render(<TextInput {...defaultProps} />);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: 'new value' } });
expect(defaultProps.onChange).toHaveBeenCalledWith({
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'string',
unsavedValue: 'new value',
});

View file

@ -26,14 +26,14 @@ export const TextInput = ({
field,
unsavedChange,
isSavingEnabled,
onChange: onChangeProp,
onInputChange,
}: TextInputProps) => {
const onChange: EuiFieldTextProps['onChange'] = (event) => {
const inputValue = event.target.value;
onUpdate({ type: field.type, unsavedValue: inputValue });
};
const onUpdate = useUpdate({ onChange: onChangeProp, field });
const onUpdate = useUpdate({ onInputChange, field });
const { id, name, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;

View file

@ -8,7 +8,7 @@
import {
FieldDefinition,
OnChangeFn,
OnInputChangeFn,
SettingType,
UnsavedFieldChange,
} from '@kbn/management-settings-types';
@ -44,6 +44,6 @@ export interface InputProps<T extends SettingType> {
>;
unsavedChange?: UnsavedFieldChange<T>;
isSavingEnabled: boolean;
/** The `onChange` handler. */
onChange: OnChangeFn<T>;
/** The `onInputChange` handler. */
onInputChange: OnInputChangeFn<T>;
}

View file

@ -19,7 +19,7 @@ For reference, this is an example of the current Advanced Settings UI:
## 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.
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 `onFieldChange` handler.
<div><img src="./assets/form_row.png" alt="Anatomy of a `FormRow`" width="1200"/></div>

View file

@ -12,12 +12,15 @@ import { action } from '@storybook/addon-actions';
import { EuiPanel } from '@elastic/eui';
import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
import { KnownTypeToMetadata, UiSettingMetadata } from '@kbn/management-settings-types/metadata';
import {
KnownTypeToMetadata,
UiSettingMetadata,
OnFieldChangeFn,
} from '@kbn/management-settings-types';
import { getDefaultValue, getUserValue } from '@kbn/management-settings-utilities/storybook';
import { getFieldDefinition } from '@kbn/management-settings-field-definition';
import { FieldRow as Component, FieldRow } from '../field_row';
import { FieldRowProvider } from '../services';
import { RowOnChangeFn } from '../types';
/**
* Props for a {@link FieldInput} Storybook story.
@ -103,10 +106,7 @@ export const storyArgs = {
* @param type The type of the UiSetting for this {@link FieldRow}.
* @returns A Storybook Story.
*/
export const getFieldRowStory = (
type: SettingType,
settingFields?: Partial<UiSettingMetadata<SettingType>>
) => {
export const getFieldRowStory = (type: SettingType, settingFields?: Partial<UiSettingMetadata>) => {
const Story = ({
isCustom,
isDeprecated,
@ -143,17 +143,17 @@ export const getFieldRowStory = (
},
});
const onChange: RowOnChangeFn<typeof type> = (_id, newChange) => {
const onFieldChange: OnFieldChangeFn<typeof type> = (_id, newChange) => {
setUnsavedChange(newChange);
action('onChange')({
action('onFieldChange')({
type,
unsavedValue: newChange?.unsavedValue,
savedValue: field.savedValue,
});
};
return <FieldRow {...{ field, unsavedChange, isSavingEnabled, onChange }} />;
return <FieldRow {...{ field, unsavedChange, isSavingEnabled, onFieldChange }} />;
};
// In Kibana, the image default value is never anything other than null. There would be a number

View file

@ -198,7 +198,7 @@ describe('Field', () => {
wrap(
<FieldRow
field={getFieldDefinition({ id, setting })}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
@ -212,7 +212,7 @@ describe('Field', () => {
wrap(
<FieldRow
field={getFieldDefinition({ id, setting })}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
@ -243,7 +243,7 @@ describe('Field', () => {
setting,
params: { isOverridden: true },
})}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
@ -265,7 +265,7 @@ describe('Field', () => {
id,
setting,
})}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={false}
/>
)
@ -288,7 +288,7 @@ describe('Field', () => {
userValue: userValues[type] as any,
},
})}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
@ -319,7 +319,7 @@ describe('Field', () => {
setting,
params: { isCustom: true },
})}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
@ -341,7 +341,7 @@ describe('Field', () => {
type,
unsavedValue: userValues[type] as any,
}}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
@ -373,7 +373,7 @@ describe('Field', () => {
});
const { getByTestId } = render(
wrap(<FieldRow field={field} onChange={handleChange} isSavingEnabled={true} />)
wrap(<FieldRow field={field} onFieldChange={handleChange} isSavingEnabled={true} />)
);
const input = getByTestId(`${DATA_TEST_SUBJ_RESET_PREFIX}-${field.id}`);
@ -395,7 +395,7 @@ describe('Field', () => {
<FieldRow
field={field}
unsavedChange={{ type, unsavedValue: userValues[type] }}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
@ -408,12 +408,12 @@ describe('Field', () => {
});
});
it('should fire onChange when input changes', () => {
it('should fire onFieldChange when input changes', () => {
const setting = settings.string;
const field = getFieldDefinition({ id: setting.name || setting.type, setting });
const { getByTestId } = render(
wrap(<FieldRow field={field} onChange={handleChange} isSavingEnabled={true} />)
wrap(<FieldRow field={field} onFieldChange={handleChange} isSavingEnabled={true} />)
);
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${field.id}`);
@ -424,12 +424,12 @@ describe('Field', () => {
});
});
it('should fire onChange with an error when input changes with invalid value', () => {
it('should fire onFieldChange with an error when input changes with invalid value', () => {
const setting = settings.color;
const field = getFieldDefinition({ id: setting.name || setting.type, setting });
const { getByTestId } = render(
wrap(<FieldRow field={field} onChange={handleChange} isSavingEnabled={true} />)
wrap(<FieldRow field={field} onFieldChange={handleChange} isSavingEnabled={true} />)
);
const input = getByTestId(`euiColorPickerAnchor ${TEST_SUBJ_PREFIX_FIELD}-${field.id}`);
@ -451,7 +451,7 @@ describe('Field', () => {
wrap(
<FieldRow
field={field}
onChange={handleChange}
onFieldChange={handleChange}
isSavingEnabled={true}
unsavedChange={{
type: setting.type,
@ -491,7 +491,11 @@ describe('Field', () => {
const { getByTestId } = render(
wrap(
<FieldRow {...{ field, unsavedChange }} onChange={handleChange} isSavingEnabled={true} />
<FieldRow
{...{ field, unsavedChange }}
onFieldChange={handleChange}
isSavingEnabled={true}
/>
)
);
@ -512,7 +516,7 @@ describe('Field', () => {
});
const { getByTestId, getByAltText } = render(
wrap(<FieldRow {...{ field }} onChange={handleChange} isSavingEnabled={true} />)
wrap(<FieldRow {...{ field }} onFieldChange={handleChange} isSavingEnabled={true} />)
);
const link = getByTestId(`${DATA_TEST_SUBJ_CHANGE_LINK_PREFIX}-${field.id}`);
@ -534,7 +538,7 @@ describe('Field', () => {
wrap(
<FieldRow
{...{ field }}
onChange={handleChange}
onFieldChange={handleChange}
unsavedChange={{ type: 'image', unsavedValue: userInputValues.image }}
isSavingEnabled={true}
/>

View file

@ -21,7 +21,8 @@ import type {
ResetInputRef,
SettingType,
UnsavedFieldChange,
OnChangeFn,
OnInputChangeFn,
OnFieldChangeFn,
} from '@kbn/management-settings-types';
import { isImageFieldDefinition } from '@kbn/management-settings-field-definition';
import { FieldInput } from '@kbn/management-settings-components-field-input';
@ -30,12 +31,11 @@ import { hasUnsavedChange } from '@kbn/management-settings-utilities';
import { FieldDescription } from './description';
import { FieldTitle } from './title';
import { useFieldStyles } from './field_row.styles';
import { RowOnChangeFn } from './types';
import { FieldInputFooter } from './footer';
export const DATA_TEST_SUBJ_SCREEN_READER_MESSAGE = 'fieldRowScreenReaderMessage';
type Definition<T extends SettingType> = Pick<
type Definition<T extends SettingType = SettingType> = Pick<
FieldDefinition<T>,
| 'ariaAttributes'
| 'defaultValue'
@ -57,18 +57,18 @@ type Definition<T extends SettingType> = Pick<
*/
export interface FieldRowProps {
/** The {@link FieldDefinition} corresponding the setting. */
field: Definition<SettingType>;
field: Definition;
/** True if saving settings is enabled, false otherwise. */
isSavingEnabled: boolean;
/** The {@link OnChangeFn} handler. */
onChange: RowOnChangeFn<SettingType>;
/** The {@link OnInputChangeFn} handler. */
onFieldChange: OnFieldChangeFn;
/**
* 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 UnsavedFieldChange} corresponding to any unsaved change to the field. */
unsavedChange?: UnsavedFieldChange<SettingType>;
unsavedChange?: UnsavedFieldChange;
}
/**
@ -76,7 +76,7 @@ export interface FieldRowProps {
* @param props The {@link FieldRowProps} for the {@link FieldRow} component.
*/
export const FieldRow = (props: FieldRowProps) => {
const { isSavingEnabled, onChange: onChangeProp, field, unsavedChange } = props;
const { isSavingEnabled, onFieldChange, field, unsavedChange } = props;
const { id, groupId, isOverridden, unsavedFieldId } = field;
const { cssFieldFormGroup } = useFieldStyles({
field,
@ -86,9 +86,9 @@ export const FieldRow = (props: FieldRowProps) => {
// Create a ref for those input fields that use a `reset` handle.
const ref = useRef<ResetInputRef>(null);
// Route any change to the `onChange` handler, along with the field id.
const onChange: OnChangeFn<SettingType> = (update) => {
onChangeProp(id, update);
// Route any change to the `onFieldChange` handler, along with the field id.
const onInputChange: OnInputChangeFn = (update) => {
onFieldChange(id, update);
};
const onReset = () => {
@ -97,9 +97,9 @@ export const FieldRow = (props: FieldRowProps) => {
const update = { type: field.type, unsavedValue: field.defaultValue };
if (hasUnsavedChange(field, update)) {
onChange(update);
onInputChange(update);
} else {
onChange();
onInputChange();
}
};
@ -111,9 +111,9 @@ export const FieldRow = (props: FieldRowProps) => {
// Indicate a field is being cleared for a new value by setting its unchanged
// value to`undefined`. Currently, this only applies to `image` fields.
if (field.savedValue !== undefined && field.savedValue !== null) {
onChange({ type: field.type, unsavedValue: undefined });
onInputChange({ type: field.type, unsavedValue: undefined });
} else {
onChange();
onInputChange();
}
};
@ -162,7 +162,7 @@ export const FieldRow = (props: FieldRowProps) => {
<FieldInput
isSavingEnabled={isSavingEnabled && !isOverridden}
isInvalid={unsavedChange?.isInvalid}
{...{ field, unsavedChange, ref, onChange }}
{...{ field, unsavedChange, ref, onInputChange }}
/>
{unsavedScreenReaderMessage}
</>

View file

@ -9,13 +9,11 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { SettingType } from '@kbn/management-settings-types';
import { wrap } from '../mocks';
import { InputResetLink, InputResetLinkProps } from './reset_link';
describe('InputResetLink', () => {
const defaultProps: InputResetLinkProps<SettingType> = {
const defaultProps: InputResetLinkProps = {
field: {
type: 'string',
id: 'test',

View file

@ -21,7 +21,7 @@ import { isFieldDefaultValue } from '@kbn/management-settings-utilities';
/**
* Props for a {@link InputResetLink} component.
*/
export interface InputResetLinkProps<T extends SettingType> {
export interface InputResetLinkProps<T extends SettingType = SettingType> {
/** The {@link FieldDefinition} corresponding the setting. */
field: Pick<
FieldDefinition<T>,

View file

@ -6,12 +6,11 @@
* Side Public License, v 1.
*/
export { FieldRow, type FieldRowProps as FieldProps } from './field_row';
export { FieldRow, type FieldRowProps } from './field_row';
export { FieldRowProvider, FieldRowKibanaProvider, type FieldRowProviderProps } from './services';
export type {
FieldRowServices,
FieldRowKibanaDependencies,
RowOnChangeFn,
KibanaDependencies,
Services,
} from './types';

View file

@ -12,7 +12,6 @@ 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.
@ -43,13 +42,3 @@ export interface KibanaDependencies {
* 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 RowOnChangeFn<T extends SettingType> = (
id: string,
change?: UnsavedFieldChange<T>
) => void;

View file

@ -7,22 +7,20 @@
*/
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { FieldDefinition, SettingType } from '@kbn/management-settings-types';
import { getFieldDefinitions } from '@kbn/management-settings-field-definition';
import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock';
import { TEST_SUBJ_PREFIX_FIELD } from '@kbn/management-settings-components-field-input/input';
import { Form } from './form';
import { wrap, getSettingsMock, createFormServicesMock, uiSettingsClientMock } from './mocks';
import { TEST_SUBJ_PREFIX_FIELD } from '@kbn/management-settings-components-field-input/input';
import { wrap, createFormServicesMock, uiSettingsClientMock } from './mocks';
import { DATA_TEST_SUBJ_SAVE_BUTTON, DATA_TEST_SUBJ_CANCEL_BUTTON } from './bottom_bar/bottom_bar';
import { FormServices } from './types';
const settingsMock = getSettingsMock();
const fields: Array<FieldDefinition<SettingType>> = getFieldDefinitions(
settingsMock,
uiSettingsClientMock
);
const fields: FieldDefinition[] = getFieldDefinitions(settingsMock, uiSettingsClientMock);
describe('Form', () => {
beforeEach(() => {
@ -78,14 +76,18 @@ describe('Form', () => {
fireEvent.change(input, { target: { value: 'test' } });
const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
fireEvent.click(saveButton);
act(() => {
fireEvent.click(saveButton);
});
expect(services.saveChanges).toHaveBeenCalledWith({
string: { type: 'string', unsavedValue: 'test' },
await waitFor(() => {
expect(services.saveChanges).toHaveBeenCalledWith({
string: { type: 'string', unsavedValue: 'test' },
});
});
});
it('clears changes when Cancel button is clicked', () => {
it('clears changes when Cancel button is clicked', async () => {
const { getByTestId } = render(wrap(<Form fields={fields} isSavingEnabled={false} />));
const testFieldType = 'string';
@ -93,12 +95,16 @@ describe('Form', () => {
fireEvent.change(input, { target: { value: 'test' } });
const cancelButton = getByTestId(DATA_TEST_SUBJ_CANCEL_BUTTON);
fireEvent.click(cancelButton);
act(() => {
fireEvent.click(cancelButton);
});
expect(input).toHaveValue(settingsMock[testFieldType].value);
await waitFor(() => {
expect(input).toHaveValue(settingsMock[testFieldType].value);
});
});
it('fires showError when saving is unsuccessful', () => {
it('fires showError when saving is unsuccessful', async () => {
const services: FormServices = createFormServicesMock();
const saveChangesWithError = jest.fn(() => {
throw new Error('Unable to save');
@ -114,15 +120,19 @@ describe('Form', () => {
fireEvent.change(input, { target: { value: 'test' } });
const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
fireEvent.click(saveButton);
act(() => {
fireEvent.click(saveButton);
});
expect(testServices.showError).toHaveBeenCalled();
await waitFor(() => {
expect(testServices.showError).toHaveBeenCalled();
});
});
it('fires showReloadPagePrompt when changing a reloadPageRequired setting', async () => {
const services: FormServices = createFormServicesMock();
// Make all settings require a page reload
const testFields: Array<FieldDefinition<SettingType>> = getFieldDefinitions(
const testFields: FieldDefinition[] = getFieldDefinitions(
getSettingsMock(true),
uiSettingsClientMock
);
@ -135,7 +145,9 @@ describe('Form', () => {
fireEvent.change(input, { target: { value: 'test' } });
const saveButton = getByTestId(DATA_TEST_SUBJ_SAVE_BUTTON);
fireEvent.click(saveButton);
act(() => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(services.showReloadPagePrompt).toHaveBeenCalled();

View file

@ -9,9 +9,10 @@
import React, { Fragment } from 'react';
import type { FieldDefinition } from '@kbn/management-settings-types';
import { FieldRow, RowOnChangeFn } from '@kbn/management-settings-components-field-row';
import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
import { FieldCategories } from '@kbn/management-settings-components-field-category';
import { UnsavedFieldChange, OnFieldChangeFn } from '@kbn/management-settings-types';
import { isEmpty } from 'lodash';
import { categorizeFields } from '@kbn/management-settings-utilities';
import { BottomBar } from './bottom_bar';
import { useSave } from './use_save';
@ -20,7 +21,7 @@ import { useSave } from './use_save';
*/
export interface FormProps {
/** A list of {@link FieldDefinition} corresponding to settings to be displayed in the form. */
fields: Array<FieldDefinition<SettingType>>;
fields: FieldDefinition[];
/** True if saving settings is enabled, false otherwise. */
isSavingEnabled: boolean;
}
@ -32,9 +33,9 @@ export interface FormProps {
export const Form = (props: FormProps) => {
const { fields, isSavingEnabled } = props;
const [unsavedChanges, setUnsavedChanges] = React.useState<
Record<string, UnsavedFieldChange<SettingType>>
>({});
const [unsavedChanges, setUnsavedChanges] = React.useState<Record<string, UnsavedFieldChange>>(
{}
);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
@ -53,7 +54,7 @@ export const Form = (props: FormProps) => {
setIsLoading(false);
};
const onChange: RowOnChangeFn<SettingType> = (id, change) => {
const onFieldChange: OnFieldChangeFn = (id, change) => {
if (!change) {
const { [id]: unsavedChange, ...rest } = unsavedChanges;
setUnsavedChanges(rest);
@ -63,15 +64,16 @@ export const Form = (props: FormProps) => {
setUnsavedChanges((changes) => ({ ...changes, [id]: change }));
};
const fieldRows = fields.map((field) => {
const { id: key } = field;
const unsavedChange = unsavedChanges[key];
return <FieldRow {...{ key, field, unsavedChange, onChange, isSavingEnabled }} />;
});
const categorizedFields = categorizeFields(fields);
/** TODO - Querying is not enabled yet. */
const onClearQuery = () => {};
return (
<Fragment>
<div>{fieldRows}</div>
<FieldCategories
{...{ categorizedFields, isSavingEnabled, onFieldChange, onClearQuery, unsavedChanges }}
/>
{!isEmpty(unsavedChanges) && (
<BottomBar
onSaveAll={saveAll}

View file

@ -7,5 +7,4 @@
*/
export { TestWrapper, createFormServicesMock, wrap } from './context';
export { getSettingsMock } from './settings';
export { uiSettingsClientMock } from './settings_client';

View file

@ -6,13 +6,13 @@
* Side Public License, v 1.
*/
import {
FieldRowProvider,
FieldRowKibanaProvider,
} from '@kbn/management-settings-components-field-row';
import React, { FC, useContext } from 'react';
import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
import { UnsavedFieldChange } from '@kbn/management-settings-types';
import {
FieldCategoryKibanaProvider,
FieldCategoryProvider,
} from '@kbn/management-settings-components-field-category';
import type { FormServices, FormKibanaDependencies, Services } from './types';
import { reloadPageToast } from './reload_page_toast';
@ -33,7 +33,7 @@ export const FormProvider = ({ children, ...services }: FormProviderProps) => {
return (
<FormContext.Provider value={{ saveChanges, showError, showReloadPagePrompt }}>
<FieldRowProvider {...rest}>{children}</FieldRowProvider>
<FieldCategoryProvider {...rest}>{children}</FieldCategoryProvider>
</FormContext.Provider>
);
};
@ -47,7 +47,7 @@ export const FormKibanaProvider: FC<FormKibanaDependencies> = ({ children, ...de
return (
<FormContext.Provider
value={{
saveChanges: (changes: Record<string, UnsavedFieldChange<SettingType>>) => {
saveChanges: (changes: Record<string, UnsavedFieldChange>) => {
const arr = Object.entries(changes).map(([key, value]) =>
settings.client.set(key, value.unsavedValue)
);
@ -57,7 +57,9 @@ export const FormKibanaProvider: FC<FormKibanaDependencies> = ({ children, ...de
showReloadPagePrompt: () => toasts.add(reloadPageToast(theme, i18nStart)),
}}
>
<FieldRowKibanaProvider {...{ docLinks, toasts }}>{children}</FieldRowKibanaProvider>
<FieldCategoryKibanaProvider {...{ docLinks, toasts }}>
{children}
</FieldCategoryKibanaProvider>
</FormContext.Provider>
);
};

View file

@ -6,12 +6,13 @@
* Side Public License, v 1.
*/
import React from 'react';
import { EuiPanel } from '@elastic/eui';
import { action } from '@storybook/addon-actions';
import { ComponentMeta } from '@storybook/react';
import { FieldDefinition, SettingType } from '@kbn/management-settings-types';
import { FieldDefinition } from '@kbn/management-settings-types';
import { getFieldDefinitions } from '@kbn/management-settings-field-definition';
import { getSettingsMock, uiSettingsClientMock } from '../mocks';
import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock';
import { uiSettingsClientMock } from '../mocks';
import { Form as Component } from '../form';
import { FormProvider } from '../services';
@ -37,12 +38,15 @@ export default {
showError={action('showError')}
showReloadPagePrompt={action('showReloadPagePrompt')}
>
<EuiPanel>
<Story />
</EuiPanel>
<Story />
</FormProvider>
),
],
parameters: {
backgrounds: {
default: 'ghost',
},
},
} as ComponentMeta<typeof Component>;
interface FormStoryProps {
@ -53,7 +57,7 @@ interface FormStoryProps {
}
export const Form = ({ isSavingEnabled, requirePageReload }: FormStoryProps) => {
const fields: Array<FieldDefinition<SettingType>> = getFieldDefinitions(
const fields: FieldDefinition[] = getFieldDefinitions(
getSettingsMock(requirePageReload),
uiSettingsClientMock
);

View file

@ -30,5 +30,7 @@
"@kbn/core-theme-browser",
"@kbn/core-ui-settings-browser",
"@kbn/management-settings-components-field-input",
"@kbn/management-settings-components-field-category",
"@kbn/management-settings-utilities",
]
}

View file

@ -10,7 +10,7 @@ import type {
FieldRowKibanaDependencies,
FieldRowServices,
} from '@kbn/management-settings-components-field-row';
import { SettingType, UnsavedFieldChange } from '@kbn/management-settings-types';
import { UnsavedFieldChange } from '@kbn/management-settings-types';
import { SettingsStart } from '@kbn/core-ui-settings-browser';
import { I18nStart } from '@kbn/core-i18n-browser';
import { ThemeServiceStart } from '@kbn/core-theme-browser';
@ -20,7 +20,7 @@ import { ToastsStart } from '@kbn/core-notifications-browser';
* Contextual services used by a {@link Form} component.
*/
export interface Services {
saveChanges: (changes: Record<string, UnsavedFieldChange<SettingType>>) => void;
saveChanges: (changes: Record<string, UnsavedFieldChange>) => void;
showError: (message: string) => void;
showReloadPagePrompt: () => void;
}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { FieldDefinition, SettingType } from '@kbn/management-settings-types';
import type { FieldDefinition } from '@kbn/management-settings-types';
import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import { UnsavedFieldChange } from '@kbn/management-settings-types';
@ -14,7 +14,7 @@ import { useServices } from './services';
export interface UseSaveParameters {
/** All {@link FieldDefinition} in the form. */
fields: Array<FieldDefinition<SettingType>>;
fields: FieldDefinition[];
/** The function to invoke for clearing all unsaved changes. */
clearChanges: () => void;
}
@ -28,7 +28,7 @@ export interface UseSaveParameters {
export const useSave = (params: UseSaveParameters) => {
const { saveChanges, showError, showReloadPagePrompt } = useServices();
return async (changes: Record<string, UnsavedFieldChange<SettingType>>) => {
return async (changes: Record<string, UnsavedFieldChange>) => {
if (isEmpty(changes)) {
return;
}

View file

@ -7,7 +7,7 @@
*/
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { FieldDefinition, SettingType, UiSettingMetadata } from '@kbn/management-settings-types';
import { FieldDefinition, UiSettingMetadata } from '@kbn/management-settings-types';
import { getFieldDefinition } from './get_definition';
type SettingsClient = Pick<IUiSettingsClient, 'isCustom' | 'isOverridden'>;
@ -21,9 +21,9 @@ type SettingsClient = Pick<IUiSettingsClient, 'isCustom' | 'isOverridden'>;
* @returns An array of {@link FieldDefinition} objects.
*/
export const getFieldDefinitions = (
settings: Record<string, UiSettingMetadata<SettingType>>,
settings: Record<string, UiSettingMetadata>,
client: SettingsClient
): Array<FieldDefinition<SettingType>> =>
): FieldDefinition[] =>
Object.entries(settings).map(([id, setting]) =>
getFieldDefinition({
id,

View file

@ -26,13 +26,12 @@ import {
MarkdownFieldDefinition,
NumberFieldDefinition,
SelectFieldDefinition,
SettingType,
StringFieldDefinition,
UndefinedFieldDefinition,
} from '@kbn/management-settings-types';
/** Simplifed type for a {@link FieldDefinition} */
type Definition = Pick<FieldDefinition<SettingType>, 'type'>;
type Definition = Pick<FieldDefinition, 'type'>;
/**
* Returns `true` if the given {@link FieldDefinition} is an {@link ArrayFieldDefinition},

View file

@ -27,12 +27,11 @@ import {
SelectUnsavedFieldChange,
StringUnsavedFieldChange,
UndefinedUnsavedFieldChange,
SettingType,
UnsavedFieldChange,
} from '@kbn/management-settings-types';
/** Simplifed type for a {@link UnsavedFieldChange} */
type Change = UnsavedFieldChange<SettingType>;
type Change = UnsavedFieldChange;
/**
* Returns `true` if the given {@link FieldUnsavedChange} is an {@link ArrayUnsavedFieldChange},

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FieldDefinition } from './field_definition';
export interface CategorizedFields {
[category: string]: {
count: number;
fields: FieldDefinition[];
};
}

View file

@ -20,7 +20,10 @@ import { KnownTypeToValue, SettingType } from './setting_type';
* representing a UiSetting).
* @public
*/
export interface FieldDefinition<T extends SettingType, V = KnownTypeToValue<T> | null> {
export interface FieldDefinition<
T extends SettingType = SettingType,
V = KnownTypeToValue<T> | null
> {
/** UX ARIA attributes derived from the setting. */
ariaAttributes: {
/** The `aria-label` attribute for the field input. */

View file

@ -51,6 +51,7 @@ export type {
StringUnsavedFieldChange,
UndefinedUnsavedFieldChange,
UnsavedFieldChange,
UnsavedFieldChanges,
} from './unsaved_change';
export type {
@ -64,6 +65,8 @@ export type {
Value,
} from './setting_type';
export type { CategorizedFields } from './category';
/**
* A React `ref` that indicates an input can be reset using an
* imperative handle.
@ -76,4 +79,16 @@ export type ResetInputRef = {
* A function that is called when the value of a {@link FieldInput} changes.
* @param change The {@link UnsavedFieldChange} passed to the handler.
*/
export type OnChangeFn<T extends SettingType> = (change?: UnsavedFieldChange<T>) => void;
export type OnInputChangeFn<T extends SettingType = SettingType> = (
change?: UnsavedFieldChange<T>
) => void;
/**
* An `onFieldChange` handler when a Field changes.
* @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 OnFieldChangeFn<T extends SettingType = SettingType> = (
id: string,
change?: UnsavedFieldChange<T>
) => void;

View file

@ -21,8 +21,10 @@ export type UiSetting<T> = PublicUiSettingsParams & UserProvidedValues<T>;
*
* @public
*/
export interface UiSettingMetadata<T extends SettingType, V = KnownTypeToValue<T> | null>
extends UiSetting<V> {
export interface UiSettingMetadata<
T extends SettingType = SettingType,
V = KnownTypeToValue<T> | null
> extends UiSetting<V> {
/**
* The type of setting being represented.
* @see{@link SettingType}

View file

@ -63,7 +63,7 @@ export type Value = string | boolean | number | Array<string | number> | undefin
* given {@link SettingType}.
* @public
*/
export type KnownTypeToValue<T extends SettingType> =
export type KnownTypeToValue<T extends SettingType = SettingType> =
T extends 'color' | 'image' | 'json' | 'markdown' | 'select' | 'string' ? string :
T extends 'boolean' ? boolean :
T extends 'number' | 'bigint' ? number :

View file

@ -13,7 +13,7 @@ import { KnownTypeToValue, SettingType } from './setting_type';
* yet been saved.
* @public
*/
export interface UnsavedFieldChange<T extends SettingType> {
export interface UnsavedFieldChange<T extends SettingType = SettingType> {
/**
* The type of setting.
* @see {@link SettingType}
@ -125,3 +125,5 @@ export type KnownTypeToUnsavedChange<T extends SettingType> =
T extends 'string' ? StringUnsavedFieldChange:
T extends 'undefined' ? UndefinedUnsavedFieldChange :
never;
export type UnsavedFieldChanges = Record<string, UnsavedFieldChange>;

View file

@ -0,0 +1,22 @@
/*
* 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 { CategorizedFields, FieldDefinition } from '@kbn/management-settings-types';
export const categorizeFields = (fields: FieldDefinition[]): CategorizedFields => {
// Group settings by category
return fields.reduce((grouped: CategorizedFields, field) => {
const category = field.categories[0];
const group = grouped[category] || { count: 0, fields: [] };
group.fields = [...group.fields, field];
group.count = group.fields.length;
grouped[category] = group;
return grouped;
}, {});
};

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
const upperFirst = (str = '') => str.replace(/^./, (strng) => strng.toUpperCase());
const names: Record<string, string> = {
general: i18n.translate('management.settings.categoryNames.generalLabel', {
defaultMessage: 'General',
}),
machineLearning: i18n.translate('management.settings.categoryNames.machineLearningLabel', {
defaultMessage: 'Machine Learning',
}),
observability: i18n.translate('management.settings.categoryNames.observabilityLabel', {
defaultMessage: 'Observability',
}),
timelion: i18n.translate('management.settings.categoryNames.timelionLabel', {
defaultMessage: 'Timelion',
}),
notifications: i18n.translate('management.settings.categoryNames.notificationsLabel', {
defaultMessage: 'Notifications',
}),
visualizations: i18n.translate('management.settings.categoryNames.visualizationsLabel', {
defaultMessage: 'Visualizations',
}),
discover: i18n.translate('management.settings.categoryNames.discoverLabel', {
defaultMessage: 'Discover',
}),
dashboard: i18n.translate('management.settings.categoryNames.dashboardLabel', {
defaultMessage: 'Dashboard',
}),
reporting: i18n.translate('management.settings.categoryNames.reportingLabel', {
defaultMessage: 'Reporting',
}),
search: i18n.translate('management.settings.categoryNames.searchLabel', {
defaultMessage: 'Search',
}),
securitySolution: i18n.translate('management.settings.categoryNames.securitySolutionLabel', {
defaultMessage: 'Security Solution',
}),
enterpriseSearch: i18n.translate('management.settings.categoryNames.enterpriseSearchLabel', {
defaultMessage: 'Enterprise Search',
}),
};
export function getCategoryName(category?: string) {
return category ? names[category] || upperFirst(category) : '';
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { categorizeFields } from './categorize_fields';
export { getCategoryName } from './get_category_name';

View file

@ -6,31 +6,33 @@
* Side Public License, v 1.
*/
import type { FieldDefinition, SettingType, OnChangeFn } from '@kbn/management-settings-types';
import type { FieldDefinition, SettingType, OnInputChangeFn } from '@kbn/management-settings-types';
import { hasUnsavedChange } from './has_unsaved_change';
export interface UseUpdateParameters<T extends SettingType> {
/** The {@link OnChangeFn} to invoke. */
onChange: OnChangeFn<T>;
/** The {@link OnInputChangeFn} to invoke. */
onInputChange: OnInputChangeFn<T>;
/** The {@link FieldDefinition} to use to create an update. */
field: Pick<FieldDefinition<T>, 'defaultValue' | 'savedValue'>;
}
/**
* Hook to provide a standard {@link OnChangeFn} that will send an update to the
* Hook to provide a standard {@link OnInputChangeFn} that will send an update to the
* field.
*
* @param params The {@link UseUpdateParameters} to use.
* @returns An {@link OnChangeFn} that will send an update to the field.
* @returns An {@link OnInputChangeFn} that will send an update to the field.
*/
export const useUpdate = <T extends SettingType>(params: UseUpdateParameters<T>): OnChangeFn<T> => {
const { onChange, field } = params;
export const useUpdate = <T extends SettingType>(
params: UseUpdateParameters<T>
): OnInputChangeFn<T> => {
const { onInputChange, field } = params;
return (update) => {
if (hasUnsavedChange(field, update)) {
onChange(update);
onInputChange(update);
} else {
onChange();
onInputChange();
}
};
};

View file

@ -7,6 +7,7 @@
*/
export { isSettingDefaultValue, normalizeSettings } from './setting';
export {
getFieldInputValue,
hasUnsavedChange,
@ -14,3 +15,5 @@ export {
useUpdate,
type UseUpdateParameters,
} from './field';
export { categorizeFields, getCategoryName } from './category';

View file

@ -14,13 +14,15 @@ type Settings = {
/**
* A utility function returning a representative set of UiSettings.
* @param requirePageReload The value of the `requirePageReload` param for all settings.
* @param requiresPageReload The value of the `requirePageReload` param for all settings.
*/
export const getSettingsMock = (requirePageReload: boolean = false): Settings => {
export const getSettingsMock = (
requiresPageReload: boolean = false,
readonly: boolean = false
): Settings => {
const defaults = {
requiresPageReload: requirePageReload,
readonly: false,
category: ['category'],
requiresPageReload,
readonly,
};
return {
@ -29,7 +31,8 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
name: 'array:test:setting',
type: 'array',
userValue: null,
value: ['example_value'],
value: ['foo', 'bar', 'baz'],
category: ['general', 'dashboard'],
...defaults,
},
boolean: {
@ -38,6 +41,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'boolean',
userValue: null,
value: true,
category: ['general', 'dashboard'],
...defaults,
},
color: {
@ -46,6 +50,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'color',
userValue: null,
value: '#FF00CC',
category: ['general', 'dashboard'],
...defaults,
},
image: {
@ -54,6 +59,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'image',
userValue: null,
value: '',
category: ['dashboard', 'discover'],
...defaults,
},
number: {
@ -62,6 +68,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'number',
userValue: null,
value: 1,
category: ['dashboard', 'discover'],
...defaults,
},
json: {
@ -70,6 +77,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'json',
userValue: null,
value: '{"foo": "bar"}',
category: ['dashboard', 'discover'],
...defaults,
},
markdown: {
@ -78,6 +86,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'markdown',
userValue: null,
value: '',
category: ['notifications', 'search'],
...defaults,
},
select: {
@ -92,6 +101,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'select',
userValue: null,
value: 'apple',
category: ['notifications', 'search'],
...defaults,
},
string: {
@ -100,6 +110,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'string',
userValue: null,
value: 'hello world',
category: ['notifications', 'search'],
...defaults,
},
undefined: {
@ -108,6 +119,7 @@ export const getSettingsMock = (requirePageReload: boolean = false): Settings =>
type: 'undefined',
userValue: null,
value: undefined,
category: ['notifications', 'search'],
...defaults,
},
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { SettingType, UiSettingMetadata, Value } from '@kbn/management-settings-types';
import { UiSettingMetadata, Value } from '@kbn/management-settings-types';
import isEqual from 'lodash/isEqual';
/**
@ -17,7 +17,7 @@ import isEqual from 'lodash/isEqual';
* @returns True if the provided value is equal to the setting's default value, false otherwise.
*/
export const isSettingDefaultValue = (
setting: UiSettingMetadata<SettingType>,
setting: UiSettingMetadata,
userValue: Value = setting.userValue
) => {
const { value } = setting;

View file

@ -92,10 +92,8 @@ const deriveValue = (type: SettingType, value: unknown): Value => {
* may be missing the `type` or `value` properties.
* @returns A mapped collection of normalized {@link UiSetting} objects.
*/
export const normalizeSettings = (
rawSettings: RawSettings
): Record<string, UiSettingMetadata<SettingType>> => {
const normalizedSettings: Record<string, UiSettingMetadata<SettingType>> = {};
export const normalizeSettings = (rawSettings: RawSettings): Record<string, UiSettingMetadata> => {
const normalizedSettings: Record<string, UiSettingMetadata> = {};
const entries = Object.entries(rawSettings);

View file

@ -17,5 +17,6 @@
],
"kbn_references": [
"@kbn/management-settings-types",
"@kbn/i18n",
]
}

View file

@ -20,3 +20,19 @@ import jest from 'jest-mock';
/* @ts-expect-error TS doesn't see jest as a property of window, and I don't want to edit our global config. */
window.jest = jest;
export const parameters = {
backgrounds: {
default: 'body',
values: [
{
name: 'body',
value: '##f7f8fc',
},
{
name: 'ghost',
value: '#fff',
},
],
},
};

View file

@ -962,6 +962,8 @@
"@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-category": ["packages/kbn-management/settings/components/field_category"],
"@kbn/management-settings-components-field-category/*": ["packages/kbn-management/settings/components/field_category/*"],
"@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"],

View file

@ -4867,6 +4867,10 @@
version "0.0.0"
uid ""
"@kbn/management-settings-components-field-category@link:packages/kbn-management/settings/components/field_category":
version "0.0.0"
uid ""
"@kbn/management-settings-components-field-input@link:packages/kbn-management/settings/components/field_input":
version "0.0.0"
uid ""