mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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.   ### 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—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—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:
parent
8f129de52a
commit
b9cab392b2
57 changed files with 688 additions and 171 deletions
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>());
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({});
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
{...{
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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: '' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -32,6 +32,9 @@ const createRootMock = () => {
|
|||
|
||||
export const createFieldInputServicesMock = (): FieldInputServices => ({
|
||||
showDanger: jest.fn(),
|
||||
validateChange: async () => {
|
||||
return { successfulValidation: true, valid: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const TestWrapper = ({
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -29,5 +29,6 @@
|
|||
"@kbn/core-theme-browser-mocks",
|
||||
"@kbn/core-i18n-browser",
|
||||
"@kbn/core-analytics-browser-mocks",
|
||||
"@kbn/core-ui-settings-browser",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -201,6 +201,8 @@ function mockConfig() {
|
|||
},
|
||||
};
|
||||
},
|
||||
validateValue: (key: string, value: any) =>
|
||||
Promise.resolve({ successfulValidation: true, valid: true }),
|
||||
};
|
||||
return {
|
||||
core: {
|
||||
|
|
|
@ -129,6 +129,8 @@ describe('Settings Helper', () => {
|
|||
...imageSetting,
|
||||
};
|
||||
},
|
||||
validateValue: (key: string, value: any) =>
|
||||
Promise.resolve({ successfulValidation: true, valid: true }),
|
||||
};
|
||||
|
||||
it('mapConfig', () => {
|
||||
|
|
|
@ -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 }),
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', {
|
||||
|
|
20
src/plugins/discover/server/config.ts
Normal file
20
src/plugins/discover/server/config.ts
Normal 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,
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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/**/*"
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -33,6 +33,7 @@ exports[`Header rendering renders correctly against snapshot 1`] = `
|
|||
"isOverridden": [MockFunction],
|
||||
"remove": [MockFunction],
|
||||
"set": [MockFunction],
|
||||
"validateValue": [MockFunction],
|
||||
},
|
||||
"updated$": Subject {
|
||||
"closed": false,
|
||||
|
|
|
@ -30,6 +30,7 @@ export const createMockConfig = (): ConfigType => {
|
|||
settings: getDefaultConfigSettings(),
|
||||
experimentalFeatures: parseExperimentalConfigValue(enableExperimental).features,
|
||||
enabled: true,
|
||||
enableUiSettingsValidations: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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 d’enregistrer l'image",
|
||||
"management.settings.field.invalidIconLabel": "Non valide",
|
||||
|
|
|
@ -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": "無効",
|
||||
|
|
|
@ -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": "无效",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue