[Serverless][Advanced Settings] Add validations on user input (#170234)

Addresses https://github.com/elastic/kibana/issues/168684

## Summary

This PR adds validations of the user input for a some of the uiSettings
(see https://github.com/elastic/kibana/issues/168684) based on the
`schema` that is provided in the setting definitions. We do this by
exposing a `validateValue` API from the uiSettings browser-side client,
which calls an internal validate route to the server. Then
`validateValue` is called in the `onChange` handlers of the setting
input fields, utilising `debounce` to reduce the number of requests to
the server.

Note: One of the validation requirements is for validating that the
`defaultIndex` setting value is an existing data view. This is a more
complicated validation and will be addressed in a separate PR.


![defaultAnomalyScore](4fb99993-9c2d-4c92-8ea4-bacdb58f7be4)

![defaultThreatIndex](264fa663-43cb-44a1-9435-4de0fa1e957a)


### How to test
**Testing the validations in serverless:**
1. Start Es with `yarn es serverless` and Kibana with `yarn
serverless-{es/oblt/security}`
2. Go to Management -> Advanced settings
3. Verify that the UI provides correct validation error messages for
[the settings that require
validation](https://github.com/elastic/kibana/issues/168684).

**Verify that self-managed Kibana is not affected:**
There should be no validations in self-managed Kibana and setting an
invalid value for an advanced setting should be allowed and shouldn't
cause any errors.

@elastic/kibana-core team, I know you're no longer code owners of the
uiSettings service, but any feedback on those changes will be highly
appreciated. 🙏

<!---
### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
-->

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Elena Stoeva 2023-11-16 18:07:51 +00:00 committed by GitHub
parent 8f129de52a
commit b9cab392b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 688 additions and 171 deletions

View file

@ -150,10 +150,14 @@ xpack.reporting.queue.pollInterval: 3m
xpack.reporting.roles.enabled: false
xpack.reporting.statefulSettings.enabled: false
# Disabled Observability plugins
xpack.ux.enabled: false
xpack.monitoring.enabled: false
xpack.uptime.enabled: false
xpack.legacy_uptime.enabled: false
monitoring.ui.enabled: false
## Enable uiSettings validations
xpack.securitySolution.enableUiSettingsValidations: true
data.enableUiSettingsValidations: true
discover.enableUiSettingsValidations: true

View file

@ -253,3 +253,26 @@ Array [
],
]
`;
exports[`#validate rejects on 301 1`] = `"Moved Permanently"`;
exports[`#validate rejects on 404 response 1`] = `"Request failed with status code: 404"`;
exports[`#validate rejects on 500 1`] = `"Request failed with status code: 500"`;
exports[`#validate sends a validation request: validation request 1`] = `
Array [
Array [
"/foo/bar/internal/kibana/settings/foo/validate",
Object {
"headers": Object {
"accept": "application/json",
"content-type": "application/json",
"kbn-version": "kibanaVersion",
"x-elastic-internal-origin": "Kibana",
},
"method": "POST",
},
],
]
`;

View file

@ -345,3 +345,45 @@ describe('#stop', () => {
await batchSetPromise;
});
});
describe('#validate', () => {
it('sends a validation request', async () => {
fetchMock.mock('*', {
body: { errorMessage: 'Test validation error message.' },
});
const { uiSettingsApi } = setup();
await uiSettingsApi.validate('foo', 'bar');
expect(fetchMock.calls()).toMatchSnapshot('validation request');
});
it('rejects on 404 response', async () => {
fetchMock.mock('*', {
status: 404,
body: 'not found',
});
const { uiSettingsApi } = setup();
await expect(uiSettingsApi.validate('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
});
it('rejects on 301', async () => {
fetchMock.mock('*', {
status: 301,
body: 'redirect',
});
const { uiSettingsApi } = setup();
await expect(uiSettingsApi.validate('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
});
it('rejects on 500', async () => {
fetchMock.mock('*', {
status: 500,
body: 'redirect',
});
const { uiSettingsApi } = setup();
await expect(uiSettingsApi.validate('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot();
});
});

View file

@ -16,6 +16,11 @@ export interface UiSettingsApiResponse {
settings: UiSettingsState;
}
export interface ValidationApiResponse {
valid: boolean;
errorMessage?: string;
}
interface Changes {
values: {
[key: string]: any;
@ -94,6 +99,15 @@ export class UiSettingsApi {
});
}
/**
* Sends a validation request to the server for the provided key+value pair.
*/
public async validate(key: string, value: any): Promise<ValidationApiResponse> {
return await this.sendRequest('POST', `/internal/kibana/settings/${key}/validate`, {
value,
});
}
/**
* Gets an observable that notifies subscribers of the current number of active requests
*/

View file

@ -10,6 +10,9 @@ import { Subject } from 'rxjs';
import { materialize, take, toArray } from 'rxjs/operators';
import { UiSettingsClient } from './ui_settings_client';
import { ValidationApiResponse } from './ui_settings_api';
const TEST_VALIDATION_ERROR_MESSAGE = 'Test validation message.';
let done$: Subject<unknown>;
@ -22,6 +25,12 @@ function setup(options: { defaults?: any; initialSettings?: any } = {}) {
const batchSetGlobal = jest.fn(() => ({
settings: {},
}));
const validate = jest.fn(
(): ValidationApiResponse => ({
valid: false,
errorMessage: TEST_VALIDATION_ERROR_MESSAGE,
})
);
done$ = new Subject();
const client = new UiSettingsClient({
defaults,
@ -29,11 +38,12 @@ function setup(options: { defaults?: any; initialSettings?: any } = {}) {
api: {
batchSet,
batchSetGlobal,
validate,
} as any,
done$,
});
return { client, batchSet, batchSetGlobal };
return { client, batchSet, batchSetGlobal, validate };
}
afterEach(() => {
@ -283,3 +293,27 @@ describe('#getUpdate$', () => {
expect(onComplete).toHaveBeenCalled();
});
});
describe('#validateValue', () => {
it('resolves to a ValueValidation', async () => {
const { client } = setup();
await expect(client.validateValue('foo', 'bar')).resolves.toMatchObject({
successfulValidation: true,
valid: false,
errorMessage: TEST_VALIDATION_ERROR_MESSAGE,
});
});
it('resolves to a ValueValidation on failure', async () => {
const { client, validate } = setup();
validate.mockImplementation(() => {
throw new Error('Error in request');
});
await expect(client.validateValue('foo', 'bar')).resolves.toMatchObject({
successfulValidation: false,
});
});
});

View file

@ -128,6 +128,19 @@ You can use \`IUiSettingsClient.get("${key}", defaultValue)\`, which will just r
return this.updateErrors$.asObservable();
}
async validateValue(key: string, value: unknown) {
try {
const resp = await this.api.validate(key, value);
const isValid = resp.valid;
return isValid
? { successfulValidation: true, valid: true }
: { successfulValidation: true, valid: false, errorMessage: resp.errorMessage };
} catch (error) {
this.updateErrors$.next(error);
return { successfulValidation: false };
}
}
protected assertUpdateAllowed(key: string) {
if (this.isOverridden(key)) {
throw new Error(

View file

@ -22,6 +22,7 @@ export const clientMock = () => {
isOverridden: jest.fn(),
getUpdate$: jest.fn(),
getUpdateErrors$: jest.fn(),
validateValue: jest.fn(),
};
mock.get$.mockReturnValue(new Subject<any>());
mock.getUpdate$.mockReturnValue(new Subject<any>());

View file

@ -16,6 +16,12 @@ export interface UiSettingsState {
[key: string]: PublicUiSettingsParams & UserProvidedValues;
}
export interface ValueValidation {
successfulValidation: boolean;
valid?: boolean;
errorMessage?: string;
}
/**
* Client-side client that provides access to the advanced settings stored in elasticsearch.
* The settings provide control over the behavior of the Kibana application.
@ -100,6 +106,11 @@ export interface IUiSettingsClient {
* the settings, containing the actual Error class.
*/
getUpdateErrors$: () => Observable<Error>;
/**
* Validates a uiSettings value and returns a ValueValidation object.
*/
validateValue: (key: string, value: any) => Promise<ValueValidation>;
}
/** @public */

View file

@ -10,6 +10,7 @@ import { omit } from 'lodash';
import type { Logger } from '@kbn/logging';
import type { UiSettingsParams, UserProvidedValues } from '@kbn/core-ui-settings-common';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import { ValidationBadValueError, ValidationSettingNotFoundError } from '../ui_settings_errors';
export interface BaseUiSettingsDefaultsClientOptions {
overrides?: Record<string, any>;
@ -72,6 +73,24 @@ export abstract class BaseUiSettingsClient implements IUiSettingsClient {
return !!definition?.sensitive;
}
async validate(key: string, value: unknown) {
if (!value) {
throw new ValidationBadValueError();
}
const definition = this.defaults[key];
if (!definition) {
throw new ValidationSettingNotFoundError(key);
}
if (definition.schema) {
try {
definition.schema.validate(value);
} catch (error) {
return { valid: false, errorMessage: error.message };
}
}
return { valid: true };
}
protected validateKey(key: string, value: unknown) {
const definition = this.defaults[key];
if (value === null || definition === undefined) return;

View file

@ -13,7 +13,11 @@ import { mockCreateOrUpgradeSavedConfig } from './ui_settings_client.test.mock';
import { SavedObjectsClient } from '@kbn/core-saved-objects-api-server-internal';
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
import { UiSettingsClient } from './ui_settings_client';
import { CannotOverrideError } from '../ui_settings_errors';
import {
CannotOverrideError,
ValidationBadValueError,
ValidationSettingNotFoundError,
} from '../ui_settings_errors';
const logger = loggingSystemMock.create().get();
@ -732,6 +736,48 @@ describe('ui settings', () => {
});
});
describe('#validate()', () => {
it('returns a correct validation response for an existing setting key and an invalid value', async () => {
const defaults = { foo: { schema: schema.number() } };
const { uiSettings } = setup({ defaults });
expect(await uiSettings.validate('foo', 'testValue')).toMatchObject({
valid: false,
errorMessage: 'expected value of type [number] but got [string]',
});
});
it('returns a correct validation response for an existing setting key and a valid value', async () => {
const defaults = { foo: { schema: schema.number() } };
const { uiSettings } = setup({ defaults });
expect(await uiSettings.validate('foo', 5)).toMatchObject({ valid: true });
});
it('throws for a non-existing setting key', async () => {
const { uiSettings } = setup();
try {
await uiSettings.validate('bar', 5);
} catch (error) {
expect(error).toBeInstanceOf(ValidationSettingNotFoundError);
expect(error.message).toBe('Setting with a key [bar] does not exist.');
}
});
it('throws for a null value', async () => {
const defaults = { foo: { schema: schema.number() } };
const { uiSettings } = setup({ defaults });
try {
await uiSettings.validate('foo', null);
} catch (error) {
expect(error).toBeInstanceOf(ValidationBadValueError);
expect(error.message).toBe('No value was specified.');
}
});
});
describe('caching', () => {
describe('read operations cache user config', () => {
beforeEach(() => {

View file

@ -11,10 +11,12 @@ import { registerInternalDeleteRoute } from './delete';
import { registerInternalGetRoute } from './get';
import { registerInternalSetManyRoute } from './set_many';
import { registerInternalSetRoute } from './set';
import { registerInternalValidateRoute } from './validate';
export function registerInternalRoutes(router: InternalUiSettingsRouter) {
registerInternalGetRoute(router);
registerInternalDeleteRoute(router);
registerInternalSetRoute(router);
registerInternalSetManyRoute(router);
registerInternalValidateRoute(router);
}

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server';
import { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import { ValidationBadValueError, ValidationSettingNotFoundError } from '../../ui_settings_errors';
import type {
InternalUiSettingsRequestHandlerContext,
InternalUiSettingsRouter,
} from '../../internal_types';
export function registerInternalValidateRoute(router: InternalUiSettingsRouter) {
const validateFromRequest = async (
uiSettingsClient: IUiSettingsClient,
context: InternalUiSettingsRequestHandlerContext,
request: KibanaRequest<
Readonly<{} & { key: string }>,
unknown,
Readonly<{ value?: any } & {}>,
'post'
>,
response: KibanaResponseFactory
) => {
try {
const { key } = request.params;
const { value } = request.body;
const { valid, errorMessage } = await uiSettingsClient.validate(key, value);
return response.ok({
body: {
valid,
errorMessage,
},
});
} catch (error) {
if (error instanceof ValidationSettingNotFoundError) {
return response.notFound({ body: error });
}
if (error instanceof ValidationBadValueError) {
return response.badRequest({ body: error });
}
throw error;
}
};
router.post(
{
path: '/internal/kibana/settings/{key}/validate',
validate: {
params: schema.object({
key: schema.string(),
}),
body: schema.object({
value: schema.any(),
}),
},
options: { access: 'internal' },
},
async (context, request, response) => {
const uiSettingsClient = (await context.core).uiSettings.client;
return await validateFromRequest(uiSettingsClient, context, request, response);
}
);
}

View file

@ -27,3 +27,15 @@ export class SettingNotRegisteredError extends Error {
);
}
}
export class ValidationSettingNotFoundError extends Error {
constructor(key: string) {
super(`Setting with a key [${key}] does not exist.`);
}
}
export class ValidationBadValueError extends Error {
constructor() {
super('No value was specified.');
}
}

View file

@ -27,6 +27,7 @@ const createClientMock = () => {
removeMany: jest.fn(),
isOverridden: jest.fn(),
isSensitive: jest.fn(),
validate: jest.fn(),
};
mocked.get.mockResolvedValue(false);
mocked.getAll.mockResolvedValue({});

View file

@ -8,6 +8,11 @@
import type { UserProvidedValues, UiSettingsParams } from '@kbn/core-ui-settings-common';
interface ValueValidation {
valid: boolean;
errorMessage?: string;
}
/**
* Server-side client that provides access to the advanced settings stored in elasticsearch.
* The settings provide control over the behavior of the Kibana application.
@ -57,4 +62,8 @@ export interface IUiSettingsClient {
* Shows whether the uiSetting is a sensitive value. Used by telemetry to not send sensitive values.
*/
isSensitive: (key: string) => boolean;
/**
* Validates the uiSettings value and returns a ValueValidation object.
*/
validate: (key: string, value: unknown) => Promise<ValueValidation>;
}

View file

@ -40,6 +40,13 @@ export const SettingsApplication: Story = () => {
showReloadPagePrompt={action('showReloadPagePrompt')}
subscribeToUpdates={() => new Subscription()}
addUrlToHistory={action('addUrlToHistory')}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
>
<Component />
</SettingsApplicationProvider>

View file

@ -32,7 +32,10 @@ export type SettingsApplicationServices = Services & FormServices;
export interface KibanaDependencies {
settings: {
client: Pick<IUiSettingsClient, 'getAll' | 'isCustom' | 'isOverridden' | 'getUpdate$'>;
client: Pick<
IUiSettingsClient,
'getAll' | 'isCustom' | 'isOverridden' | 'getUpdate$' | 'validateValue'
>;
};
history: ScopedHistory;
}
@ -52,6 +55,7 @@ export const SettingsApplicationProvider: FC<SettingsApplicationServices> = ({
const {
saveChanges,
showError,
validateChange,
showReloadPagePrompt,
links,
showDanger,
@ -72,7 +76,9 @@ export const SettingsApplicationProvider: FC<SettingsApplicationServices> = ({
addUrlToHistory,
}}
>
<FormProvider {...{ saveChanges, showError, showReloadPagePrompt, links, showDanger }}>
<FormProvider
{...{ saveChanges, showError, validateChange, showReloadPagePrompt, links, showDanger }}
>
{children}
</FormProvider>
</SettingsApplicationContext.Provider>

View file

@ -53,6 +53,13 @@ export const Categories: Story<Params> = (params) => {
<FieldCategoryProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
>
<Component
{...{

View file

@ -67,6 +67,13 @@ export const Category = ({ isFiltered, category, isSavingEnabled }: FieldCategor
<FieldCategoryProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
{...{ isSavingEnabled, onFieldChange }}
>
<Component category={category} fieldCount={count} onClearQuery={onClearQuery}>

View file

@ -75,7 +75,16 @@ export const getStory = (title: string, description: string) =>
},
decorators: [
(Story) => (
<FieldInputProvider showDanger={action('showDanger')}>
<FieldInputProvider
showDanger={action('showDanger')}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
>
<EuiPanel style={{ width: 500 }}>
<Story />
</EuiPanel>

View file

@ -6,8 +6,7 @@
* 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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ArrayInput } from './array_input';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
@ -60,34 +59,17 @@ describe('ArrayInput', () => {
expect(screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`)).toHaveValue('foo, bar, baz');
});
it('formats array when blurred', () => {
it('calls the onInputChange prop when the value changes', async () => {
render(wrap(<ArrayInput {...defaultProps} />));
const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.focus(input);
userEvent.type(input, ',baz');
expect(input).toHaveValue('foo, bar,baz');
input.blur();
expect(input).toHaveValue('foo, bar, baz');
});
fireEvent.change(input, { target: { value: 'foo, bar,baz' } });
it('only calls onInputChange when blurred ', () => {
render(wrap(<ArrayInput {...defaultProps} />));
const input = screen.getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.focus(input);
userEvent.type(input, ',baz');
expect(input).toHaveValue('foo, bar,baz');
expect(defaultProps.onInputChange).not.toHaveBeenCalled();
act(() => {
input.blur();
});
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'array',
unsavedValue: ['foo', 'bar', 'baz'],
});
await waitFor(() =>
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'array',
unsavedValue: ['foo', 'bar', 'baz'],
})
);
});
it('disables the input when isDisabled prop is true', () => {

View file

@ -6,12 +6,14 @@
* Side Public License, v 1.
*/
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui';
import { getFieldInputValue } from '@kbn/management-settings-utilities';
import { useUpdate } from '@kbn/management-settings-utilities';
import { debounce } from 'lodash';
import { useServices } from '../services';
import { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
@ -33,29 +35,45 @@ export const ArrayInput = ({
}: ArrayInputProps) => {
const [inputValue] = getFieldInputValue(field, unsavedChange) || [];
const [value, setValue] = useState(inputValue?.join(', '));
const { validateChange } = useServices();
const onUpdate = useUpdate({ onInputChange, field });
const onChange: EuiFieldTextProps['onChange'] = (event) => {
const updateValue = useCallback(
async (newValue: string, onUpdateFn) => {
const parsedValue = newValue
.replace(REGEX, ',')
.split(',')
.filter((v) => v !== '');
const validationResponse = await validateChange(field.id, parsedValue);
if (validationResponse.successfulValidation && !validationResponse.valid) {
onUpdateFn({
type: field.type,
unsavedValue: parsedValue,
isInvalid: !validationResponse.valid,
error: validationResponse.errorMessage,
});
} else {
onUpdateFn({ type: field.type, unsavedValue: parsedValue });
}
},
[validateChange, field.id, field.type]
);
const debouncedUpdateValue = useMemo(() => {
// Trigger update 1000 ms after the user stopped typing to reduce validation requests to the server
return debounce(updateValue, 1000);
}, [updateValue]);
const onChange: EuiFieldTextProps['onChange'] = async (event) => {
const newValue = event.target.value;
setValue(newValue);
await debouncedUpdateValue(newValue, onUpdate);
};
const onUpdate = useUpdate({ onInputChange, field });
useEffect(() => {
setValue(inputValue?.join(', '));
}, [inputValue]);
// In the past, each keypress would invoke the `onChange` callback. This
// is likely wasteful, so we've switched it to `onBlur` instead.
const onBlur = (event: React.ChangeEvent<HTMLInputElement>) => {
const blurValue = event.target.value
.replace(REGEX, ',')
.split(',')
.filter((v) => v !== '');
onUpdate({ type: field.type, unsavedValue: blurValue });
setValue(blurValue.join(', '));
};
const { id, name, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;
@ -66,7 +84,7 @@ export const ArrayInput = ({
disabled={!isSavingEnabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
{...{ name, onBlur, onChange, value }}
{...{ name, onChange, value }}
/>
);
};

View file

@ -6,13 +6,13 @@
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { SettingType } from '@kbn/management-settings-types';
import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities';
import { debounce } from 'lodash';
import { useServices } from '../services';
import { CodeEditor, CodeEditorProps } from '../code_editor';
import type { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
@ -45,39 +45,48 @@ export const CodeEditorInput = ({
defaultValue,
onInputChange,
}: CodeEditorInputProps) => {
// @ts-expect-error
const [inputValue] = getFieldInputValue(field, unsavedChange);
const [value, setValue] = useState(inputValue);
const { validateChange } = useServices();
const onUpdate = useUpdate({ onInputChange, field });
const onChange: CodeEditorProps['onChange'] = (inputValue) => {
let newUnsavedValue;
let errorParams = {};
const updateValue = useCallback(
async (newValue: string, onUpdateFn) => {
const isJsonArray = Array.isArray(JSON.parse(defaultValue || '{}'));
const parsedValue = newValue || (isJsonArray ? '[]' : '{}');
const validationResponse = await validateChange(field.id, parsedValue);
if (validationResponse.successfulValidation && !validationResponse.valid) {
onUpdateFn({
type: field.type,
unsavedValue: newValue,
isInvalid: !validationResponse.valid,
error: validationResponse.errorMessage,
});
} else {
onUpdateFn({ type: field.type, unsavedValue: newValue });
}
},
[validateChange, field.id, field.type, defaultValue]
);
switch (type) {
case 'json':
const isJsonArray = Array.isArray(JSON.parse(defaultValue || '{}'));
newUnsavedValue = inputValue || (isJsonArray ? '[]' : '{}');
const debouncedUpdateValue = useMemo(() => {
// Trigger update 1000 ms after the user stopped typing to reduce validation requests to the server
return debounce(updateValue, 1000);
}, [updateValue]);
try {
JSON.parse(newUnsavedValue);
} catch (e) {
errorParams = {
error: i18n.translate('management.settings.field.codeEditorSyntaxErrorMessage', {
defaultMessage: 'Invalid JSON syntax',
}),
isInvalid: true,
};
}
break;
default:
newUnsavedValue = inputValue;
}
onUpdate({ type: field.type, unsavedValue: inputValue, ...errorParams });
const onChange: CodeEditorProps['onChange'] = async (newValue) => {
// @ts-expect-error
setValue(newValue);
await debouncedUpdateValue(newValue, onUpdate);
};
useEffect(() => {
setValue(inputValue);
}, [inputValue]);
const { id, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;
// @ts-expect-error
const [value] = getFieldInputValue(field, unsavedChange);
return (
<div>

View file

@ -16,6 +16,7 @@ import { CodeEditorProps } from '../code_editor';
const name = 'Some json field';
const id = 'some:json:field';
const initialValue = '{"foo":"bar"}';
import { wrap } from '../mocks';
jest.mock('../code_editor', () => ({
CodeEditor: ({ value, onChange }: CodeEditorProps) => (
@ -55,51 +56,46 @@ describe('JsonEditorInput', () => {
});
it('renders without errors', () => {
const { container } = render(<CodeEditorInput {...defaultProps} />);
const { container } = render(wrap(<CodeEditorInput {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const { getByTestId } = render(wrap(<CodeEditorInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(initialValue);
});
it('calls the onInputChange prop when the object value changes', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
it('calls the onInputChange prop when the object value changes', async () => {
const { getByTestId } = render(wrap(<CodeEditorInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '{"bar":"foo"}' } });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '{"bar":"foo"}',
});
await waitFor(() =>
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '{"bar":"foo"}',
})
);
});
it('calls the onInputChange prop when the object value changes with no value', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
it('calls the onInputChange prop when the object value changes with no value', async () => {
const { getByTestId } = render(wrap(<CodeEditorInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '' } });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' });
await waitFor(() =>
expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' })
);
});
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.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '{"bar" "foo"}',
error: 'Invalid JSON syntax',
isInvalid: true,
});
});
it('calls the onInputChange prop when the array value changes', () => {
it('calls the onInputChange prop when the array value changes', async () => {
const props = { ...defaultProps, defaultValue: '["bar", "foo"]', value: undefined };
const { getByTestId } = render(<CodeEditorInput {...props} />);
const { getByTestId } = render(wrap(<CodeEditorInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '["foo", "bar", "baz"]' } });
waitFor(() =>
await waitFor(() =>
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '["foo", "bar", "baz"]',
@ -107,28 +103,18 @@ describe('JsonEditorInput', () => {
);
});
it('calls the onInputChange prop when the array value changes with no value', () => {
it('calls the onInputChange prop when the array value changes with no value', async () => {
const props = {
...defaultProps,
defaultValue: '["bar", "foo"]',
value: '["bar", "foo"]',
};
const { getByTestId } = render(<CodeEditorInput {...props} />);
const { getByTestId } = render(wrap(<CodeEditorInput {...props} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '' } });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' });
});
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.onInputChange).toHaveBeenCalledWith({
type: 'json',
unsavedValue: '["bar", "foo" | "baz"]',
error: 'Invalid JSON syntax',
isInvalid: true,
});
await waitFor(() =>
expect(defaultProps.onInputChange).toHaveBeenCalledWith({ type: 'json', unsavedValue: '' })
);
});
});

View file

@ -7,11 +7,12 @@
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { CodeEditorInput, CodeEditorInputProps } from './code_editor_input';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
import { CodeEditorProps } from '../code_editor';
import { wrap } from '../mocks';
const name = 'Some markdown field';
const id = 'some:markdown:field';
@ -55,23 +56,26 @@ describe('MarkdownEditorInput', () => {
});
it('renders without errors', () => {
const { container } = render(<CodeEditorInput {...defaultProps} />);
const { container } = render(wrap(<CodeEditorInput {...defaultProps} />));
expect(container).toBeInTheDocument();
});
it('renders the value prop', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
const { getByTestId } = render(wrap(<CodeEditorInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
expect(input).toHaveValue(initialValue);
});
it('calls the onInputChange prop when the value changes', () => {
const { getByTestId } = render(<CodeEditorInput {...defaultProps} />);
it('calls the onInputChange prop when the value changes', async () => {
const { getByTestId } = render(wrap(<CodeEditorInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '# New Markdown Title' } });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'markdown',
unsavedValue: '# New Markdown Title',
});
await waitFor(() =>
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'markdown',
unsavedValue: '# New Markdown Title',
})
);
});
});

View file

@ -65,14 +65,17 @@ describe('NumberInput', () => {
expect(input).toHaveValue(4321);
});
it('calls the onInputChange prop when the value changes', () => {
it('calls the onInputChange prop when the value changes', async () => {
const { getByTestId } = render(wrap(<NumberInput {...defaultProps} />));
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`);
fireEvent.change(input, { target: { value: '54321' } });
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'number',
unsavedValue: 54321,
});
await waitFor(() =>
expect(defaultProps.onInputChange).toHaveBeenCalledWith({
type: 'number',
unsavedValue: 54321,
})
);
});
it('disables the input when isDisabled prop is true', () => {

View file

@ -6,13 +6,15 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiFieldNumber, EuiFieldNumberProps } from '@elastic/eui';
import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities';
import { debounce } from 'lodash';
import { InputProps } from '../types';
import { TEST_SUBJ_PREFIX_FIELD } from '.';
import { useServices } from '../services';
/**
* Props for a {@link NumberInput} component.
@ -28,18 +30,45 @@ export const NumberInput = ({
isSavingEnabled,
onInputChange,
}: NumberInputProps) => {
const onChange: EuiFieldNumberProps['onChange'] = (event) => {
const inputValue = Number(event.target.value);
onUpdate({ type: field.type, unsavedValue: inputValue });
const [inputValue] = getFieldInputValue(field, unsavedChange) || undefined;
const [value, setValue] = useState(inputValue);
const { validateChange } = useServices();
const onUpdate = useUpdate({ onInputChange, field });
const updateValue = useCallback(
async (newValue: number, onUpdateFn) => {
const validationResponse = await validateChange(field.id, newValue);
if (validationResponse.successfulValidation && !validationResponse.valid) {
onUpdateFn({
type: field.type,
unsavedValue: newValue,
isInvalid: !validationResponse.valid,
error: validationResponse.errorMessage,
});
} else {
onUpdateFn({ type: field.type, unsavedValue: newValue });
}
},
[validateChange, field.id, field.type]
);
const debouncedUpdateValue = useMemo(() => {
// Trigger update 500 ms after the user stopped typing to reduce validation requests to the server
return debounce(updateValue, 500);
}, [updateValue]);
const onChange: EuiFieldNumberProps['onChange'] = async (event) => {
const newValue = Number(event.target.value);
setValue(newValue);
await debouncedUpdateValue(newValue, onUpdate);
};
const onUpdate = useUpdate({ onInputChange, field });
useEffect(() => {
setValue(inputValue);
}, [inputValue]);
const { id, name, ariaAttributes } = field;
const { ariaLabel, ariaDescribedBy } = ariaAttributes;
const [rawValue] = getFieldInputValue(field, unsavedChange);
const value = rawValue === null ? undefined : rawValue;
return (
<EuiFieldNumber

View file

@ -32,6 +32,9 @@ const createRootMock = () => {
export const createFieldInputServicesMock = (): FieldInputServices => ({
showDanger: jest.fn(),
validateChange: async () => {
return { successfulValidation: true, valid: true };
},
});
export const TestWrapper = ({

View file

@ -17,9 +17,13 @@ const FieldInputContext = React.createContext<FieldInputServices | null>(null);
export const FieldInputProvider: FC<FieldInputServices> = ({ children, ...services }) => {
// Typescript types are widened to accept more than what is needed. Take only what is necessary
// so the context remains clean.
const { showDanger } = services;
const { showDanger, validateChange } = services;
return <FieldInputContext.Provider value={{ showDanger }}>{children}</FieldInputContext.Provider>;
return (
<FieldInputContext.Provider value={{ showDanger, validateChange }}>
{children}
</FieldInputContext.Provider>
);
};
/**
@ -28,11 +32,15 @@ export const FieldInputProvider: FC<FieldInputServices> = ({ children, ...servic
export const FieldInputKibanaProvider: FC<FieldInputKibanaDependencies> = ({
children,
notifications: { toasts },
settings: { client },
}) => {
return (
<FieldInputContext.Provider
value={{
showDanger: (message) => toasts.addDanger(message),
validateChange: async (key, value) => {
return await client.validateValue(key, value);
},
}}
>
{children}

View file

@ -29,5 +29,6 @@
"@kbn/core-theme-browser-mocks",
"@kbn/core-i18n-browser",
"@kbn/core-analytics-browser-mocks",
"@kbn/core-ui-settings-browser",
]
}

View file

@ -13,6 +13,8 @@ import {
UnsavedFieldChange,
} from '@kbn/management-settings-types';
import { ToastsStart } from '@kbn/core-notifications-browser';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { ValueValidation } from '@kbn/core-ui-settings-browser/src/types';
/**
* Contextual services used by a {@link FieldInput} component.
@ -23,6 +25,7 @@ export interface FieldInputServices {
* @param value The message to display.
*/
showDanger: (value: string) => void;
validateChange: (key: string, value: any) => Promise<ValueValidation>;
}
/**
@ -34,6 +37,9 @@ export interface FieldInputKibanaDependencies {
notifications: {
toasts: Pick<ToastsStart, 'addDanger'>;
};
settings: {
client: IUiSettingsClient;
};
}
/**

View file

@ -78,6 +78,13 @@ export const getStory = (
<FieldRowProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
>
<EuiPanel>
<Story />

View file

@ -29,11 +29,11 @@ export interface FieldRowProviderProps extends FieldRowServices {
export const FieldRowProvider = ({ children, ...services }: FieldRowProviderProps) => {
// Typescript types are widened to accept more than what is needed. Take only what is necessary
// so the context remains clean.
const { links, showDanger } = services;
const { links, showDanger, validateChange } = services;
return (
<FieldRowContext.Provider value={{ links }}>
<FieldInputProvider {...{ showDanger }}>{children}</FieldInputProvider>
<FieldInputProvider {...{ showDanger, validateChange }}>{children}</FieldInputProvider>
</FieldRowContext.Provider>
);
};
@ -45,6 +45,7 @@ export const FieldRowKibanaProvider: FC<FieldRowKibanaDependencies> = ({
children,
docLinks,
notifications,
settings,
}) => {
return (
<FieldRowContext.Provider
@ -52,7 +53,9 @@ export const FieldRowKibanaProvider: FC<FieldRowKibanaDependencies> = ({
links: docLinks.links.management,
}}
>
<FieldInputKibanaProvider {...{ notifications }}>{children}</FieldInputKibanaProvider>
<FieldInputKibanaProvider {...{ notifications, settings }}>
{children}
</FieldInputKibanaProvider>
</FieldRowContext.Provider>
);
};

View file

@ -56,7 +56,7 @@ export const FormKibanaProvider: FC<FormKibanaDependencies> = ({ children, ...de
return (
<FormContext.Provider value={services}>
<FieldCategoryKibanaProvider {...{ docLinks, notifications }}>
<FieldCategoryKibanaProvider {...{ docLinks, notifications, settings }}>
{children}
</FieldCategoryKibanaProvider>
</FormContext.Provider>

View file

@ -37,6 +37,13 @@ export default {
saveChanges={action('saveChanges')}
showError={action('showError')}
showReloadPagePrompt={action('showReloadPagePrompt')}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
>
<Story />
</FormProvider>

View file

@ -156,6 +156,47 @@ describe('ui settings service', () => {
});
});
describe('validate', () => {
it('returns correct validation error message for invalid value', async () => {
const response = await request
.post(root, '/internal/kibana/settings/custom/validate')
.send({ value: 100 })
.expect(200);
expect(response.body).toMatchObject({
valid: false,
errorMessage: 'expected value of type [string] but got [number]',
});
});
it('returns no validation error message for valid value', async () => {
const response = await request
.post(root, '/internal/kibana/settings/custom/validate')
.send({ value: 'test' })
.expect(200);
expect(response.body).toMatchObject({ valid: true });
});
it('returns a 404 for non-existing key', async () => {
const response = await request
.post(root, '/internal/kibana/settings/test/validate')
.send({ value: 'test' })
.expect(404);
expect(response.body.message).toBe('Setting with a key [test] does not exist.');
});
it('returns a 400 for a null value', async () => {
const response = await request
.post(root, '/internal/kibana/settings/test/validate')
.send({ value: null })
.expect(400);
expect(response.body.message).toBe('No value was specified.');
});
});
describe('global', () => {
describe('set', () => {
it('validates value', async () => {

View file

@ -201,6 +201,8 @@ function mockConfig() {
},
};
},
validateValue: (key: string, value: any) =>
Promise.resolve({ successfulValidation: true, valid: true }),
};
return {
core: {

View file

@ -129,6 +129,8 @@ describe('Settings Helper', () => {
...imageSetting,
};
},
validateValue: (key: string, value: any) =>
Promise.resolve({ successfulValidation: true, valid: true }),
};
it('mapConfig', () => {

View file

@ -28,4 +28,6 @@ export const uiSettings: IUiSettingsClient = {
getAll: (): Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>> => {
return {};
},
validateValue: (key: string, value: any) =>
Promise.resolve({ successfulValidation: true, valid: true }),
};

View file

@ -86,6 +86,10 @@ export const searchConfigSchema = schema.object({
export const configSchema = schema.object({
search: searchConfigSchema,
/**
* Turns on/off limit validations for the registered uiSettings.
*/
enableUiSettingsValidations: schema.boolean({ defaultValue: false }),
});
export type ConfigSchema = TypeOf<typeof configSchema>;

View file

@ -74,9 +74,11 @@ export class DataServerPlugin
private readonly kqlTelemetryService: KqlTelemetryService;
private readonly queryService = new QueryService();
private readonly logger: Logger;
private readonly config: ConfigSchema;
constructor(initializerContext: PluginInitializerContext<ConfigSchema>) {
this.logger = initializerContext.logger.get('data');
this.config = initializerContext.config.get();
this.searchService = new SearchService(initializerContext, this.logger);
this.scriptsService = new ScriptsService();
this.kqlTelemetryService = new KqlTelemetryService(initializerContext);
@ -90,7 +92,7 @@ export class DataServerPlugin
const querySetup = this.queryService.setup(core);
this.kqlTelemetryService.setup(core, { usageCollection });
core.uiSettings.register(getUiSettings(core.docLinks));
core.uiSettings.register(getUiSettings(core.docLinks, this.config.enableUiSettingsValidations));
const searchSetup = this.searchService.setup(core, {
bfetch,

View file

@ -32,7 +32,8 @@ const requestPreferenceOptionLabels = {
};
export function getUiSettings(
docLinks: DocLinksServiceSetup
docLinks: DocLinksServiceSetup,
enableValidations: boolean
): Record<string, UiSettingsParams<unknown>> {
return {
[UI_SETTINGS.META_FIELDS]: {
@ -463,13 +464,22 @@ export function getUiSettings(
'</a>',
},
}),
schema: schema.arrayOf(
schema.object({
from: schema.string(),
to: schema.string(),
display: schema.string(),
})
),
schema: enableValidations
? schema.arrayOf(
schema.object({
from: schema.string(),
to: schema.string(),
display: schema.string(),
}),
{ maxSize: 10 }
)
: schema.arrayOf(
schema.object({
from: schema.string(),
to: schema.string(),
display: schema.string(),
})
),
},
[UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT]: {
name: i18n.translate('data.advancedSettings.pinFiltersTitle', {

View file

@ -0,0 +1,20 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core-plugins-server';
const configSchema = schema.object({
enableUiSettingsValidations: schema.boolean({ defaultValue: false }),
});
export type ConfigSchema = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ConfigSchema> = {
schema: configSchema,
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { KibanaRequest } from '@kbn/core/server';
import { KibanaRequest, PluginInitializerContext } from '@kbn/core/server';
import { DataPluginStart } from '@kbn/data-plugin/server/plugin';
import { ColumnsFromLocatorFn, SearchSourceFromLocatorFn, TitleFromLocatorFn } from './locator';
@ -28,7 +28,9 @@ export interface DiscoverServerPluginStart {
locator: DiscoverServerPluginLocatorService;
}
export const plugin = async () => {
export { config } from './config';
export const plugin = async (context: PluginInitializerContext) => {
const { DiscoverServerPlugin } = await import('./plugin');
return new DiscoverServerPlugin();
return new DiscoverServerPlugin(context);
};

View file

@ -12,6 +12,7 @@ import type { EmbeddableSetup } from '@kbn/embeddable-plugin/server';
import type { HomeServerPluginSetup } from '@kbn/home-plugin/server';
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/common';
import type { SharePluginSetup } from '@kbn/share-plugin/server';
import { PluginInitializerContext } from '@kbn/core/server';
import type { DiscoverServerPluginStart, DiscoverServerPluginStartDeps } from '.';
import { DiscoverAppLocatorDefinition } from '../common/locator';
import { capabilitiesProvider } from './capabilities_provider';
@ -19,10 +20,17 @@ import { createSearchEmbeddableFactory } from './embeddable';
import { initializeLocatorServices } from './locator';
import { registerSampleData } from './sample_data';
import { getUiSettings } from './ui_settings';
import { ConfigSchema } from './config';
export class DiscoverServerPlugin
implements Plugin<object, DiscoverServerPluginStart, object, DiscoverServerPluginStartDeps>
{
private readonly config: ConfigSchema;
constructor(initializerContext: PluginInitializerContext<ConfigSchema>) {
this.config = initializerContext.config.get();
}
public setup(
core: CoreSetup,
plugins: {
@ -33,7 +41,7 @@ export class DiscoverServerPlugin
}
) {
core.capabilities.registerProvider(capabilitiesProvider);
core.uiSettings.register(getUiSettings(core.docLinks));
core.uiSettings.register(getUiSettings(core.docLinks, this.config.enableUiSettingsValidations));
if (plugins.home) {
registerSampleData(plugins.home.sampleData);

View file

@ -38,8 +38,12 @@ const technicalPreviewLabel = i18n.translate('discover.advancedSettings.technica
defaultMessage: 'technical preview',
});
export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record<string, UiSettingsParams> = (
docLinks: DocLinksServiceSetup
export const getUiSettings: (
docLinks: DocLinksServiceSetup,
enableValidations: boolean
) => Record<string, UiSettingsParams> = (
docLinks: DocLinksServiceSetup,
enableValidations: boolean
) => ({
[DEFAULT_COLUMNS_SETTING]: {
name: i18n.translate('discover.advancedSettings.defaultColumnsTitle', {
@ -51,7 +55,9 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record<string, U
'Columns displayed by default in the Discover app. If empty, a summary of the document will be displayed.',
}),
category: ['discover'],
schema: schema.arrayOf(schema.string()),
schema: enableValidations
? schema.arrayOf(schema.string(), { maxSize: 50 })
: schema.arrayOf(schema.string()),
},
[MAX_DOC_FIELDS_DISPLAYED]: {
name: i18n.translate('discover.advancedSettings.maxDocFieldsDisplayedTitle', {

View file

@ -75,7 +75,8 @@
"@kbn/global-search-plugin",
"@kbn/resizable-layout",
"@kbn/unsaved-changes-badge",
"@kbn/core-chrome-browser"
"@kbn/core-chrome-browser",
"@kbn/core-plugins-server"
],
"exclude": [
"target/**/*"

View file

@ -17,6 +17,7 @@ exports[`SavedObjectEdition should render normally 1`] = `
"isOverridden": [MockFunction],
"remove": [MockFunction],
"set": [MockFunction],
"validateValue": [MockFunction],
},
"globalClient": Object {
"get": [MockFunction],
@ -30,6 +31,7 @@ exports[`SavedObjectEdition should render normally 1`] = `
"isOverridden": [MockFunction],
"remove": [MockFunction],
"set": [MockFunction],
"validateValue": [MockFunction],
},
},
"theme": Object {
@ -49,6 +51,7 @@ exports[`SavedObjectEdition should render normally 1`] = `
"isOverridden": [MockFunction],
"remove": [MockFunction],
"set": [MockFunction],
"validateValue": [MockFunction],
},
}
}

View file

@ -26,6 +26,9 @@ const uiSettings: IUiSettingsClient = {
set: async () => true,
getUpdate$: () => of({ key: 'setting', newValue: get('setting'), oldValue: get('setting') }),
getUpdateErrors$: () => of(new Error()),
validateValue: async () => {
return { successfulValidation: true, valid: true };
},
};
export const getUiSettings = () => uiSettings;

View file

@ -33,6 +33,7 @@ exports[`Header rendering renders correctly against snapshot 1`] = `
"isOverridden": [MockFunction],
"remove": [MockFunction],
"set": [MockFunction],
"validateValue": [MockFunction],
},
"updated$": Subject {
"closed": false,

View file

@ -30,6 +30,7 @@ export const createMockConfig = (): ConfigType => {
settings: getDefaultConfigSettings(),
experimentalFeatures: parseExperimentalConfigValue(enableExperimental).features,
enabled: true,
enableUiSettingsValidations: false,
};
};

View file

@ -114,6 +114,7 @@ export const configSchema = schema.object({
*/
prebuiltRulesPackageVersion: schema.maybe(schema.string()),
enabled: schema.boolean({ defaultValue: true }),
enableUiSettingsValidations: schema.boolean({ defaultValue: false }),
/**
* The Max number of Bytes allowed for the `upload` endpoint response action
@ -140,6 +141,7 @@ export type ConfigSchema = TypeOf<typeof configSchema>;
export type ConfigType = Omit<ConfigSchema, 'offeringSettings'> & {
experimentalFeatures: ExperimentalFeatures;
settings: ConfigSettings;
enableUiSettingsValidations: boolean;
};
export const createConfig = (context: PluginInitializerContext): ConfigType => {

View file

@ -170,7 +170,7 @@ export class Plugin implements ISecuritySolutionPlugin {
const experimentalFeatures = config.experimentalFeatures;
initSavedObjects(core.savedObjects);
initUiSettings(core.uiSettings, experimentalFeatures);
initUiSettings(core.uiSettings, experimentalFeatures, config.enableUiSettingsValidations);
appFeaturesService.init(plugins.features);
events.forEach((eventConfig) => core.analytics.registerEventType(eventConfig));

View file

@ -57,7 +57,8 @@ const orderSettings = (settings: SettingsConfig): SettingsConfig => {
export const initUiSettings = (
uiSettings: CoreSetup['uiSettings'],
experimentalFeatures: ExperimentalFeatures
experimentalFeatures: ExperimentalFeatures,
validationsEnabled: boolean
) => {
const securityUiSettings: Record<string, UiSettingsParams<unknown>> = {
[DEFAULT_APP_REFRESH_INTERVAL]: {
@ -115,7 +116,9 @@ export const initUiSettings = (
}),
category: [APP_ID],
requiresPageReload: true,
schema: schema.arrayOf(schema.string()),
schema: validationsEnabled
? schema.arrayOf(schema.string(), { maxSize: 50 })
: schema.arrayOf(schema.string()),
},
[DEFAULT_THREAT_INDEX_KEY]: {
name: i18n.translate('xpack.securitySolution.uiSettings.defaultThreatIndexLabel', {
@ -132,7 +135,9 @@ export const initUiSettings = (
),
category: [APP_ID],
requiresPageReload: true,
schema: schema.arrayOf(schema.string()),
schema: validationsEnabled
? schema.arrayOf(schema.string(), { maxSize: 10 })
: schema.arrayOf(schema.string()),
},
[DEFAULT_ANOMALY_SCORE]: {
name: i18n.translate('xpack.securitySolution.uiSettings.defaultAnomalyScoreLabel', {
@ -149,7 +154,7 @@ export const initUiSettings = (
),
category: [APP_ID],
requiresPageReload: true,
schema: schema.number(),
schema: validationsEnabled ? schema.number({ max: 100, min: 0 }) : schema.number(),
},
[ENABLE_NEWS_FEED_SETTING]: {
name: i18n.translate('xpack.securitySolution.uiSettings.enableNewsFeedLabel', {

View file

@ -4937,7 +4937,6 @@
"management.settings.categoryNames.visualizationsLabel": "Visualisations",
"management.settings.changeImageLinkText": "Modifier l'image",
"management.settings.customSettingTooltip": "Paramètre personnalisé",
"management.settings.field.codeEditorSyntaxErrorMessage": "Syntaxe JSON non valide",
"management.settings.field.customSettingAriaLabel": "Paramètre personnalisé",
"management.settings.field.imageChangeErrorMessage": "Impossible denregistrer l'image",
"management.settings.field.invalidIconLabel": "Non valide",

View file

@ -4952,7 +4952,6 @@
"management.settings.categoryNames.visualizationsLabel": "ビジュアライゼーション",
"management.settings.changeImageLinkText": "画像を変更",
"management.settings.customSettingTooltip": "カスタム設定",
"management.settings.field.codeEditorSyntaxErrorMessage": "無効な JSON 構文",
"management.settings.field.customSettingAriaLabel": "カスタム設定",
"management.settings.field.imageChangeErrorMessage": "画像を保存できませんでした",
"management.settings.field.invalidIconLabel": "無効",

View file

@ -4951,7 +4951,6 @@
"management.settings.categoryNames.visualizationsLabel": "可视化",
"management.settings.changeImageLinkText": "更改图片",
"management.settings.customSettingTooltip": "定制设置",
"management.settings.field.codeEditorSyntaxErrorMessage": "JSON 语法无效",
"management.settings.field.customSettingAriaLabel": "定制设置",
"management.settings.field.imageChangeErrorMessage": "图片无法保存",
"management.settings.field.invalidIconLabel": "无效",