Merge branch 'master' into migration-id-gen

This commit is contained in:
restrry 2021-04-25 12:16:37 +02:00
commit 1332961a64
515 changed files with 1187463 additions and 243065 deletions

View file

@ -4,7 +4,11 @@
<titleabbrev>List rule types</titleabbrev>
++++
Retrieve a list of alerting rule types.
Retrieve a list of alerting rule types that the user is authorized to access.
Each rule type includes a list of consumer features. Within these features, users are authorized to perform either `read` or `all` operations on rules of that type. This helps determine which rule types users can read, but not create or modify.
NOTE: Some rule types are limited to specific features. These rule types are not available when <<defining-alerts, defining rules>> in <<management,Stack Management>>.
[[list-rule-types-api-request]]
==== Request

View file

@ -62,7 +62,9 @@ yarn kbn watch-bazel
=== List of Already Migrated Packages to Bazel
- @elastic/datemath
- @elastic/safer-lodash-set
- @kbn/apm-utils
- @kbn/babel-code-parser
- @kbn/babel-preset
- @kbn/config-schema
- @kbn/std

View file

@ -343,6 +343,7 @@ Failure to have auth enabled in Kibana will make for a broken UI. UI-based error
|{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud]
|The cloud plugin adds cloud specific features to Kibana.
The client-side plugin configures following values:
|{kib-repo}blob/{branch}/x-pack/plugins/console_extensions/README.md[consoleExtensions]

View file

@ -97,7 +97,7 @@
"dependencies": {
"@elastic/apm-rum": "^5.6.1",
"@elastic/apm-rum-react": "^1.2.5",
"@elastic/charts": "28.2.0",
"@elastic/charts": "29.0.0",
"@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module",
"@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4",
"@elastic/ems-client": "7.13.0",
@ -109,7 +109,7 @@
"@elastic/numeral": "^2.5.0",
"@elastic/react-search-ui": "^1.5.1",
"@elastic/request-crypto": "1.1.4",
"@elastic/safer-lodash-set": "link:packages/elastic-safer-lodash-set",
"@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set/npm_module",
"@elastic/search-ui-app-search-connector": "^1.5.0",
"@elastic/ui-ace": "0.2.3",
"@hapi/boom": "^9.1.1",
@ -437,7 +437,7 @@
"@elastic/makelogs": "^6.0.0",
"@istanbuljs/schema": "^0.1.2",
"@jest/reporters": "^26.6.2",
"@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser",
"@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module",
"@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module",
"@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode",
"@kbn/dev-utils": "link:packages/kbn-dev-utils",

View file

@ -4,7 +4,9 @@ filegroup(
name = "build",
srcs = [
"//packages/elastic-datemath:build",
"//packages/elastic-safer-lodash-set:build",
"//packages/kbn-apm-utils:build",
"//packages/kbn-babel-code-parser:build",
"//packages/kbn-babel-preset:build",
"//packages/kbn-config-schema:build",
"//packages/kbn-std:build",

View file

@ -54,7 +54,7 @@ ts_project(
js_library(
name = PKG_BASE_NAME,
srcs = [],
srcs = NPM_MODULE_EXTRA_FILES,
deps = [":tsc"] + DEPS,
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
@ -62,7 +62,6 @@ js_library(
pkg_npm(
name = "npm_module",
srcs = NPM_MODULE_EXTRA_FILES,
deps = [
":%s" % PKG_BASE_NAME,
]

View file

@ -0,0 +1,65 @@
load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm")
PKG_BASE_NAME = "elastic-safer-lodash-set"
PKG_REQUIRE_NAME = "@elastic/safer-lodash-set"
SOURCE_FILES = glob(
[
"fp/**/*",
"lodash/**/*",
"index.js",
"set.js",
"setWith.js",
],
exclude = [
"**/*.d.ts"
],
)
TYPE_FILES = glob([
"fp/**/*.d.ts",
"index.d.ts",
"set.d.ts",
"setWith.d.ts",
])
SRCS = SOURCE_FILES + TYPE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
"README.md",
]
DEPS = [
"@npm//lodash",
]
js_library(
name = PKG_BASE_NAME,
srcs = NPM_MODULE_EXTRA_FILES + [
":srcs",
],
deps = DEPS,
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [
":%s" % PKG_BASE_NAME,
]
)
filegroup(
name = "build",
srcs = [
":npm_module",
],
visibility = ["//visibility:public"],
)

View file

@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "../../build/tsbuildinfo/packages/elastic-safer-lodash-set"
"incremental": false,
},
"include": [
"**/*",

View file

@ -9,8 +9,5 @@
"build": "../../node_modules/.bin/tsc",
"kbn:bootstrap": "yarn build",
"kbn:watch": "yarn build --watch"
},
"dependencies": {
"@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set"
}
}

View file

@ -53,7 +53,7 @@ ts_project(
js_library(
name = PKG_BASE_NAME,
srcs = [],
srcs = NPM_MODULE_EXTRA_FILES,
deps = [":tsc"] + DEPS,
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
@ -61,7 +61,6 @@ js_library(
pkg_npm(
name = "npm_module",
srcs = NPM_MODULE_EXTRA_FILES,
deps = [
":%s" % PKG_BASE_NAME,
]

View file

@ -0,0 +1,71 @@
load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm")
load("@npm//@babel/cli:index.bzl", "babel")
PKG_BASE_NAME = "kbn-babel-code-parser"
PKG_REQUIRE_NAME = "@kbn/babel-code-parser"
SOURCE_FILES = glob(
[
"src/**/*",
],
exclude = [
"**/*.test.*"
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
"README.md",
]
DEPS = [
"//packages/kbn-babel-preset",
"@npm//@babel/parser",
"@npm//@babel/traverse",
"@npm//lodash",
]
babel(
name = "target",
data = [
":srcs",
".babelrc",
] + DEPS,
output_dir = True,
args = [
"./%s/src" % package_name(),
"--out-dir",
"$(@D)",
"--quiet"
],
)
js_library(
name = PKG_BASE_NAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = [":target"] + DEPS,
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [
":%s" % PKG_BASE_NAME,
]
)
filegroup(
name = "build",
srcs = [
":npm_module",
],
visibility = ["//visibility:public"],
)

View file

@ -8,10 +8,5 @@
"repository": {
"type": "git",
"url": "https://github.com/elastic/kibana/tree/master/packages/kbn-babel-code-parser"
},
"scripts": {
"build": "../../node_modules/.bin/babel src --out-dir target",
"kbn:bootstrap": "yarn build --quiet",
"kbn:watch": "yarn build --watch"
}
}

View file

@ -38,7 +38,7 @@ DEPS = [
js_library(
name = PKG_BASE_NAME,
srcs = [
srcs = NPM_MODULE_EXTRA_FILES + [
":srcs",
],
deps = DEPS,
@ -48,7 +48,6 @@ js_library(
pkg_npm(
name = "npm_module",
srcs = NPM_MODULE_EXTRA_FILES,
deps = [
":%s" % PKG_BASE_NAME,
]

View file

@ -10,7 +10,6 @@
"kbn:bootstrap": "yarn build"
},
"dependencies": {
"@elastic/safer-lodash-set": "link:../elastic-safer-lodash-set",
"@kbn/logging": "link:../kbn-logging"
},
"devDependencies": {

View file

@ -9,7 +9,7 @@ pageLoadAssetSize:
charts: 195358
cloud: 21076
console: 46091
core: 413500
core: 414000
crossClusterReplication: 65408
dashboard: 374194
dashboardEnhanced: 65646

View file

@ -45,7 +45,7 @@ peggy(
js_library(
name = PKG_BASE_NAME,
srcs = [
srcs = NPM_MODULE_EXTRA_FILES + [
":srcs",
":grammar"
],
@ -56,7 +56,6 @@ js_library(
pkg_npm(
name = "npm_module",
srcs = NPM_MODULE_EXTRA_FILES,
deps = [
":%s" % PKG_BASE_NAME,
]

View file

@ -280,6 +280,34 @@ test('accepts any type of objects for custom headers', () => {
expect(() => httpSchema.validate(obj)).not.toThrow();
});
test('forbids the "location" custom response header', () => {
const httpSchema = config.schema;
const obj = {
customResponseHeaders: {
location: 'string',
Location: 'string',
lOcAtIoN: 'string',
},
};
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot(
`"[customResponseHeaders]: The following custom response headers are not allowed to be set: location, Location, lOcAtIoN"`
);
});
test('forbids the "refresh" custom response header', () => {
const httpSchema = config.schema;
const obj = {
customResponseHeaders: {
refresh: 'string',
Refresh: 'string',
rEfReSh: 'string',
},
};
expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot(
`"[customResponseHeaders]: The following custom response headers are not allowed to be set: refresh, Refresh, rEfReSh"`
);
});
describe('with TLS', () => {
test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => {
const httpSchema = config.schema;

View file

@ -26,6 +26,9 @@ const hostURISchema = schema.uri({ scheme: ['http', 'https'] });
const match = (regex: RegExp, errorMsg: string) => (str: string) =>
regex.test(str) ? undefined : errorMsg;
// The lower-case set of response headers which are forbidden within `customResponseHeaders`.
const RESPONSE_HEADER_DENY_LIST = ['location', 'refresh'];
const configSchema = schema.object(
{
name: schema.string({ defaultValue: () => hostname() }),
@ -70,6 +73,16 @@ const configSchema = schema.object(
securityResponseHeaders: securityResponseHeadersSchema,
customResponseHeaders: schema.recordOf(schema.string(), schema.any(), {
defaultValue: {},
validate(value) {
const forbiddenKeys = Object.keys(value).filter((headerName) =>
RESPONSE_HEADER_DENY_LIST.includes(headerName.toLowerCase())
);
if (forbiddenKeys.length > 0) {
return `The following custom response headers are not allowed to be set: ${forbiddenKeys.join(
', '
)}`;
}
},
}),
host: schema.string({
defaultValue: 'localhost',

View file

@ -139,7 +139,6 @@ export const TEMPORARILY_IGNORED_PATHS = [
'test/functional/apps/management/exports/_import_objects-conflicts.json',
'x-pack/legacy/plugins/index_management/public/lib/editSettings.js',
'x-pack/legacy/plugins/license_management/public/store/reducers/licenseManagement.js',
'x-pack/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js',
'x-pack/plugins/monitoring/public/icons/health-gray.svg',
'x-pack/plugins/monitoring/public/icons/health-green.svg',
'x-pack/plugins/monitoring/public/icons/health-red.svg',
@ -150,28 +149,4 @@ export const TEMPORARILY_IGNORED_PATHS = [
'x-pack/plugins/reporting/server/export_types/common/assets/fonts/roboto/Roboto-Medium.ttf',
'x-pack/plugins/reporting/server/export_types/common/assets/fonts/roboto/Roboto-Regular.ttf',
'x-pack/plugins/reporting/server/export_types/common/assets/img/logo-grey.png',
'x-pack/test/functional/es_archives/monitoring/beats-with-restarted-instance/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/beats-with-restarted-instance/mappings.json',
'x-pack/test/functional/es_archives/monitoring/logstash-pipelines/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/logstash-pipelines/mappings.json',
'x-pack/test/functional/es_archives/monitoring/multi-basic/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/multi-basic/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-basic-beats/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-basic-beats/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-green-gold/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-green-gold/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-green-platinum/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-green-platinum/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-green-trial-two-nodes-one-cgrouped/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-green-trial-two-nodes-one-cgrouped/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-red-platinum/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-red-platinum/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-three-nodes-shard-relocation/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-three-nodes-shard-relocation/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-basic/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-basic/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum--with-10-alerts/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum--with-10-alerts/mappings.json',
'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum/data.json.gz',
'x-pack/test/functional/es_archives/monitoring/singlecluster-yellow-platinum/mappings.json',
];

View file

@ -8,6 +8,8 @@
import React, { Component } from 'react';
import { NotificationsSetup } from 'src/core/public';
import { EuiIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
@ -17,7 +19,7 @@ interface Props {
getCurl: () => Promise<string>;
getDocumentation: () => Promise<string | null>;
autoIndent: (ev: React.MouseEvent) => void;
addNotification?: (opts: { title: string }) => void;
notifications: NotificationsSetup;
}
interface State {
@ -42,25 +44,30 @@ export class ConsoleMenu extends Component<Props, State> {
});
};
copyAsCurl() {
this.copyText(this.state.curlCode);
const { addNotification } = this.props;
if (addNotification) {
addNotification({
async copyAsCurl() {
const { notifications } = this.props;
try {
await this.copyText(this.state.curlCode);
notifications.toasts.add({
title: i18n.translate('console.consoleMenu.copyAsCurlMessage', {
defaultMessage: 'Request copied as cURL',
}),
});
} catch (e) {
notifications.toasts.addError(e, {
title: i18n.translate('console.consoleMenu.copyAsCurlFailedMessage', {
defaultMessage: 'Could not copy request as cURL',
}),
});
}
}
copyText(text: string) {
const textField = document.createElement('textarea');
textField.innerText = text;
document.body.appendChild(textField);
textField.select();
document.execCommand('copy');
textField.remove();
async copyText(text: string) {
if (window.navigator?.clipboard) {
await window.navigator.clipboard.writeText(text);
return;
}
throw new Error('Could not copy to clipboard!');
}
onButtonClick = () => {
@ -107,7 +114,7 @@ export class ConsoleMenu extends Component<Props, State> {
<EuiContextMenuItem
key="Copy as cURL"
id="ConCopyAsCurl"
disabled={!document.queryCommandSupported('copy')}
disabled={!window.navigator?.clipboard}
onClick={() => {
this.closePopover();
this.copyAsCurl();

View file

@ -232,7 +232,7 @@ function EditorUI({ initialTextValue }: EditorProps) {
autoIndent={(event) => {
autoIndent(editorInstanceRef.current!, event);
}}
addNotification={({ title }) => notifications.toasts.add({ title })}
notifications={notifications}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -9,6 +9,12 @@
import { EuiScreenReaderOnly } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useRef } from 'react';
// Ensure the modes we might switch to dynamically are available
import 'brace/mode/text';
import 'brace/mode/json';
import 'brace/mode/yaml';
import { expandLiteralStrings } from '../../../../../shared_imports';
import {
useEditorReadContext,
@ -19,11 +25,25 @@ import { createReadOnlyAceEditor, CustomAceEditor } from '../../../../models/leg
import { subscribeResizeChecker } from '../subscribe_console_resize_checker';
import { applyCurrentSettings } from './apply_editor_settings';
const isJSONContentType = (contentType?: string) =>
Boolean(contentType && contentType.indexOf('application/json') >= 0);
/**
* Best effort expand literal strings
*/
const safeExpandLiteralStrings = (data: string): string => {
try {
return expandLiteralStrings(data);
} catch (e) {
return data;
}
};
function modeForContentType(contentType?: string) {
if (!contentType) {
return 'ace/mode/text';
}
if (contentType.indexOf('application/json') >= 0) {
if (isJSONContentType(contentType)) {
return 'ace/mode/json';
} else if (contentType.indexOf('application/yaml') >= 0) {
return 'ace/mode/yaml';
@ -58,16 +78,21 @@ function EditorOutputUI() {
const editor = editorInstanceRef.current!;
if (data) {
const mode = modeForContentType(data[0].response.contentType);
editor.session.setMode(mode);
editor.update(
data
.map((d) => d.response.value as string)
.map(readOnlySettings.tripleQuotes ? expandLiteralStrings : (a) => a)
.join('\n')
.map((result) => {
const { value, contentType } = result.response;
if (readOnlySettings.tripleQuotes && isJSONContentType(contentType)) {
return safeExpandLiteralStrings(value as string);
}
return value;
})
.join('\n'),
mode
);
} else if (error) {
editor.session.setMode(modeForContentType(error.response.contentType));
editor.update(error.response.value as string);
const mode = modeForContentType(error.response.contentType);
editor.update(error.response.value as string, mode);
} else {
editor.update('');
}

View file

@ -0,0 +1,96 @@
---
id: formLibCoreDefaultValue
slug: /form-lib/core/default-value
title: Default value
summary: Initiate a field with the correct value
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
There are multiple places where you can define the default value of a field. By "default value" we are saying "the initial value" of a field. Once the field is initiated it has its own internal state and can't be controlled.
## Order of precedence
1. As a prop on the `<UseField path="name" defaultValue="John" />` component
2. In the **form** `defaultValue` config passed to `useForm({ defaultValue: { ... } })`
3. In the **field** `defaultValue` config parameter (either passed as prop to `<UseField />` prop or declared inside a form schema)
4. If no default value is found above, it defaults to `""` (empty string)
### As a prop on `<UseField />`
This takes over any other `defaultValue` defined elsewhere. What you provide as prop is what you will have as default value for the field. Remember that the `<UseField />` **is not** a controlled component, so changing the `defaultValue` prop to another value does not have any effect.
```js
// Here we manually set the default value
<UseField path="user.firstName" defaultValue="John" />
```
### In the form `defaultValue` config passed to `useForm()`
The above solution works well for very small forms, but with larger form it is not very convenient to manually add the default value of each field.
```js
// Let's imagine some data coming from the server
const fetchedData = {
user: {
firstName: 'John',
lastName: 'Snow',
}
}
// We need to manually write each connection, which is not convenient
<UseField path="user.firstName" defaultValue={fetchedData.user.firstName} />
<UseField path="user.lastName" defaultValue={fetchedData.user.lastName} />
```
It is much easier to provide the `defaultValue` object (probably some data that we have fetched from the server) at the form level
```js
const { form } = useForm({ defaultValue: fetchedData });
// And the defaultValue for each field will be automatically mapped to its paths
<UseField path="user.firstName" />
<UseField path="user.lastName" />
```
### In the field `defaultValue` config parameter of the field config
When you are creating a new resource, the form is empty and there is no data coming from the server to map. You still might want to define a default value for your fields.
```js
interface Props {
fetchedData?: { foo: boolean }
}
export const MyForm = ({ fetchedData }: Props) => {
// fetchedData can be "undefined" or an object.
// If it is undefined, then the config.defaultValue will be used
const { form } = useForm({ defaultValue: fetchedData });
return (
<UseField path="foo" config={{ defaultValue: true } />
);
}
```
Or the same but using a form schema
```js
const schema = {
// Field config for the path "foo" declared below
foo: {
defaultValue: true,
},
};
export const MyComponent = ({ fetchedData }: Props) => {
// 1. If "fetchedData" is not undefined **and** there is a value at the "foo" path, use it
// 2. otherwise, if there is a schema with a config at the "foo" path, read its "defaultValue"
// 3. otherwise use an "" (empty string)
const { form } = useForm({ schema, defaultValue: fetchedData });
return (
<UseField path="foo" />
);
}
```

View file

@ -0,0 +1,188 @@
---
id: formLibCoreFieldHook
slug: /form-lib/core/field-hook
title: Field hook
summary: You don't manually create them but you'll get all the love from them
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
When you use the `<UseField />` component you receive back a `field` hook object that you can connect to your React components.
This hook has the following properties and handlers:
## Properties
### path
**Type:** `string`
The field `path`.
### label
**Type:** `string`
The field `label` provided in the config.
### labelAppend
**Type:** `string | ReactNode`
The field `labelAppend` provided in the config.
### helpText
**Type:** `string | ReactNode`
The field `helpText` provided in the config.
### type
**Type:** `string`
The field `type` provided in the config.
### value
**Type:** `T`
The field state value.
### errors
**Type:** `ValidationError[]`
An array of possible validation errors. Each error has a required `message` property and any other meta data returned by your validation(s).
### isValid
**Type:** `boolean`
Flag that indicates if the field is valid.
### isPristine
**Type:** `boolean`
Flag that indicates if the field is pristine (if it hasn't been modified by the user).
### isValidating
**Type:** `boolean`
Flag that indicates if the field is being validated. It is set to `true` when the value changes, and back to `false` right after all the validations have executed. If all your validations are synchronous, this state is always `false`.
### isValidated
**Type:** `boolean`
Flag that indicates if this field has run at least once its validation(s). The validations are run when the field values changes or, if the field value has not changed, when we call `form.submit()` or `form.validate()`.
### isChangingValue
**Type:** `boolean`
Flag that indicates if the field value is changing. If you have set the [`valueChangeDebounceTime`](use_field.md#valuechangedebouncetime) to `0`, then this state is the same as the `isValidating` state. But if you have increased the `valueChangeDebounceTime` time, then you will have a minimum value changing time. This is useful if you want to display your validation errors after a certain amount of time has passed.
## Handlers
### setValue()
**Arguments:** `value: T | (prevValue: T) => T`
**Returns:** `void`
Handler to set the value of the field.
You can either pass the value directly or provide a callback that will receive the previous field value and you will have to return the next value.
### onChange()
**Arguments:** `event: React.ChangeEvent<HTMLInputElement>`
**Returns:** `void`
Use the `onChange` helper to directly hook into the forms fields inputs `onChange` prop without having to extract the event value and call `setValue()` on the field.
```js
// Instead of this
<UseField path="name">
{({ setValue }) => {
return <input type="text" value={field.value} onChange={(e) => setValue(e.target.value)} />
}}
</UseField>
// You can use the "onChange" handler
<UseField path="name">
{({ onChange }) => {
return <input type="text" value={field.value} onChange={onChange} />
}}
</UseField>
```
### setErrors()
**Arguments:** `ValidationError[]`
**Returns:** `void`
Handler to set the errors of the field.
### clearErrors()
**Arguments:** `type?: string | string[]`
**Returns:** `void`
Handler to clear the errors of the field. You can optionally provide the type of error to clear.
See an example of typed validation when <DocLink id="formLibExampleValidation" section="validating-arrays-of-items" text="validating an array of items" />.
### getErrorsMessages()
**Arguments:** `options?: { validationType?: string; errorCode?: string }`
**Returns:** `string | null`
Returns a concatenated string with all the error messages if the field has errors, or `null` otherwise.
You can optionally provide an error code or a validation type to narrow down the errors you want to receive back.
**Note:** You can add error code to your errors by adding a `code` property to your validation errors.
```js
const nameValidator = ({ value }) => {
if (value.startsWith('.')) => {
return {
message: "The name can't start with a dot (.)",
code: 'ERR_NAME_FORMAT',
};
}
};
```
### validate()
**Arguments:** `options?: { formData?: any; value?: T; validationType?: string; }`
**Returns:** `FieldValidateResponse | Promise<FieldValidateResponse>`
Validate the field by calling all the validations declared in its config. Optionally you can provide an options object with the following properties:
* `formData` - The form data
* `value` - The value to validate
* `validationType` - The validation type to run against the value
You rarely need to manually call this method as it is automatically done for you whenever the field value changes.
**Important:** Calling `validate()` **does not update** the form `isValid` state and is only meant to get the field validity at a point in time.
#### Example where you might need this method:
The user changes the value inside one of your components and you receive this value in an `onChange` handler. Before updating the field value with `setValue()`, you want to validate this value and maybe prevent the field `value` to be updated at all.
### reset()
**Arguments:** `options?: { resetValue?: boolean; defaultValue?: T }`
**Returns:** `T | undefined`
Resets the field to its initial state. It accepts an optional configuration object:
- `resetValue` (default: `true`). Flag to indicate if we want to not only reset the field state (`errors`, `isPristine`...) but also the field value. If set to `true`, it will put back the default value passed to the field, or to the form, or declared on the field config (in that order).
- `defaultValue`. In some cases you might not want to reset the field to the default value initiallly provided. In this case you can provide a new `defaultValue` value when resetting.
If you provided a new `defaultValue`, you will receive back this value after it has gone through any possible `deserializer(s)` defined for that field. If you didn't provide a default value `undefined` is returned.

View file

@ -0,0 +1,79 @@
---
id: formLibCoreFormComponent
slug: /form-lib/core/form-component
title: <Form />
summary: The boundary of your form
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
Once you have created <DocLink id="formLibCoreFormHook" text="a form hook"/>, you can wrap your form with the `<Form />` component.
This component accepts the following props.
## Props
### form (required)
**Type:** `FormHook`
The form hook you've created with `useForm()`.
```js
const MyFormComponent = () => {
const { form } = useForm();
return (
<Form form={form}>
...
</Form>
);
};
```
### FormWrapper
**Type:** `React.ComponentType`
**Default:**: `EuiForm`
This is the component that will wrap your form fields. By default it renders the `<EuiForm />` component.
Any props that you pass to the `<Form />` component, except the `form` hook, will be forwarded to that component.
```js
const MyFormComponent = () => {
const { form } = useForm();
// "isInvalid" and "error" are 2 props from <EuiForm />
return (
<Form form={form} isInvalid={form.isSubmitted && !form.isValid} error={form.getErrors()}>
...
</Form>
);
};
```
By default, `<EuiForm />` wraps the form with a `<div>` element. In some cases semantic HTML is preferred: wrapping your form with the `<form>` element. This also allows the user to submit the form by hitting the "ENTER" key inside a field.
**Important:** Make sure to **not** declare the FormWrapper inline on the prop but outside of your component.
```js
// Create a wrapper component with the <form> element
const FormWrapper = (props: any) => <form {...props} />;
export const MyFormComponent = () => {
const { form } = useForm();
// Hitting the "ENTER" key in a textfield will submit the form.
const submitForm = async () => {
const { isValid, data } = await form.submit();
...
};
return (
<Form form={form} FormWrapper={FormWrapper} onSubmit={submitForm}>
...
</Form>
);
};
```

View file

@ -0,0 +1,214 @@
---
id: formLibCoreFormHook
slug: /form-lib/core/form-hook
title: Form hook
summary: The heart of the lib; It manages your fields so you don't have to
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
When you call `useForm()` you receive back a `form` hook object.
This object has the following properties and handlers
## Properties
### isSubmitted
**Type:** `boolean`
Flag that indicates if the form has been submitted at least once. It is set to `true` when we call <DocLink id="formLibCoreFormHook" section="submit" text="submit()"/>.
**Note:** If you have a dynamic form where fields are removed and added, the `isSubmitted` is set to `false` whenever a new field is added, as in such case the user has a new form in front of him.
### isSubmitting
**Type:** `boolean`
Flag that indicates if the form is being submitted. When we submit the form, if you have provided an <DocLink id="formLibCoreUseForm" section="onsubmitdata-isvalid" text="onSubmit() handler"/> in the config, it might take some time to resolve (e.g. an HTTP request being made). This flag will be set to `true` until the Promise resolves.
### isValid
**Type:** `boolean | undefined`
Flag that indicates if the form is valid. It can have three values:
* `true`
* `false`
* `undefined`
When the form first renders, its validity is neither `true` nor `false`. It is `undefined`, we don't know its validity. It could be valid if none of the fields are required or invalid if some field is required.
Each time a field value changes, it is validated. When **all** fields have changed (are dirty), then only the `isValid` is either `true` or `false`, as at this stage we know the form validity. Of course we will probably need to know the validity of the form without updating each field one by one. There are two ways of doing that:
* calling `form.submit()`
```js
export const MyComponent = () => {
const { form } = useForm();
const onClickSubmit = async () => {
// We validate all the form fields and get its "isValid" state (true|false)
const { isValid, data } = await form.submit();
if (isValid) {
// ...
}
};
return (
<Form form={form}>
...
<button onClick={onClickSubmit}>Submit</button>
{form.isValid === false && (
<div>Only show this message if the form validity is "false".</div>
)}
</Form>
);
}
```
* calling the `validate()` handler on the form. As you can see in the example below, as we don't use the `form.submit()`, we have to manually declare and update the `isSubmitting` and `isSubmitted` states.
**Note:** It is usually better to use `form.submit()`, but you might need at some stage to know the form validity without updating its `isSubmitted` state, and that's what `validate()` is for.
```js
export const MyComponent = ({ onFormUpdate }: Props) => {
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { form } = useForm<UserFormData>();
const onClickSubmit = async () => {
setIsSubmitted(true);
setIsSubmitting(true);
// If the "isValid" state is "undefined" (=== not all the fields are dirty),
// call validate() to run validation on all the fields.
const isValid = form.isValid ?? (await form.validate());
setIsSubmitting(false);
if (isValid) {
console.log('Form data:', form.getFormData());
}
};
const hasErrors = isSubmitted && form.isValid === false;
return (
<Form form={form}>
<UseField path="firstName" config={{ validations: [{ validator }] }} />
<UseField path="lastName" />
<button
onClick={onClickSubmit}
disabled={isSubmitting || hasErrors}
>
{isSubmitting ? 'Sending...' : 'Submit'}
</button>
{hasErrors && <div>Form is invalid.</div>}
</Form>
);
};
```
### id
**Type:** `string`
The form id. If none was provided, "default" will be returned.
## Handlers
### submit()
**Returns:** `Promise<{ data: T | {}, isValid: boolean }>`
This handler submits the form and returns its data and validity. If the form is not valid, the data will be `null` as only valid data is passed through the `serializer(s)` before being returned.
```js
const { data, isValid } = await form.submit();
```
### validate()
**Returns:** `Promise<boolean>`
Use this handler to get the validity of the form.
```js
const isFormValid = await form.validate();
```
### getFields()
**Returns:** `{ [path: string]: FieldHook }`
Access any field on the form.
```js
const { name: nameField } = form.getFields();
```
### getFormData()
**Arguments:** `options?: { unflatten?: boolean }`
**Returns:** `T | R`
Return the form data. Accepts an optional `options` with an `unflatten` parameter (defaults to `true`). If you are only interested in the raw form data, pass `unflatten: false` to the handler.
```js
const formData = form.getFormData();
const rawFormData = form.getFormData({ unflatten: false });
```
### getErrors()
**Returns:** `string[]`
Returns an array of all errors in the form.
```js
const errors = form.getErrors();
```
### reset()
**Arguments:** `options?: { resetValues?: boolean; defaultValue?: any }`
Resets the form to its initial state. It accepts an optional configuration object:
- `resetValues` (default: `true`). Flag to indicate if we want to not only reset the form state (`isValid`, `isSubmitted`...) but also the field values. If set to `true` all form values will be reset to their default value.
- `defaultValue`. In some cases you might not want to reset the form to the default value initially provided to the form (probably because it is data that came from the server and you want a clean form). In this case you can provide a new `defaultValue` object when resetting.
```js
// Reset to the defaultValue object passed to the form
// If none was provided, reset to the field config defaultValue.
form.reset();
// Reset to the default value declared on the **field config** defaultValue
form.reset({ defaultValue: {} });
// You can keep some current field value and the rest will come from the **field config** defaultValue.
form.reset({ defaultValue: { type: 'SomeValueToKeep' } });
```
### setFieldValue()
**Arguments:** `fieldName: string, value: unknown`
Sets a field value imperatively.
```js
form.setFieldValue('name', 'John');
```
### setFieldErrors()
**Arguments:** `fieldName: string, errors: ValidationError[]`
Sets field errors imperatively.
```js
form.setFieldErrors('name', [{ message: 'There is an error in the field' }]);
```

View file

@ -0,0 +1,81 @@
---
id: formLibCoreFundamentals
slug: /form-lib/core/fundamentals
title: Fundamentals
summary: Let's understand the basics
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
The core exposes the main building blocks (hooks and components) needed to build your form.
It is important to note that the core **is not** responsible for rendering UI. Its responsibility is to return form and fields **state and handlers** that you can connect to React components. The core of the form lib is agnostic of any UI rendering the form.
In Kibana we work with [the EUI component library](https://elastic.github.io/eui) so we have created <DocLink id="formLibHelpersComponents" text="field components"/> that wrap EUI form input components. With these components, connection with the form lib is already done for you.
## Main building blocks
The three required components to build a form are:
- <DocLink id="formLibCoreUseForm" text="useForm()"/> hook to declare a new <DocLink id="formLibCoreFormHook" text="form"/>
- <DocLink id="formLibCoreFormComponent" text="<Form />"/> component that will wrap your form and create a context for it
- <DocLink id="formLibCoreUseField" text="<UseField />"/> component to declare a <DocLink id="formLibCoreFieldHook" text="field"/>
Let's see them in action before going into details
```js
import { useForm, Form, UseField } from 'src/plugins/es_ui_shared/public';
export const UserForm = () => {
const { form } = useForm(); // 1
return (
<Form form={form}> // 2
<UseField path="name" /> // 3
<UseField path="lastName" />
<button onClick={form.submit}>Submit</button>
</Form>
);
};
```
1. We use the `useForm` hook to declare a new form.
2. We then wrap our form with the `<Form />` component, providing the `form` that we have just created.
3. Finally, we declared two fields with the `<UseField />` component, providing a unique `path` for each one of them.
If you were to run this code in the browser and click on the "Submit" button nothing would happen as we haven't defined any handler to execute when submitting the form. Let's do that now along with providing a `UserFormData` interface to the form, which we will get back in our `onSubmit` handler.
```js
import { useForm, Form, UseField, FormConfig } from 'src/plugins/es_ui_shared/public';
interface UserFormData {
name: string;
lastName: string;
}
export const UserForm = () => {
const onFormSubmit: FormConfig<UserFormData>['onSubmit'] = async (data, isValid) => {
console.log("Is form valid:", isValid);
if (!isValid) {
// Maybe show a callout?
return;
}
console.log("Form data:", data);
};
const { form } = useForm({ onSubmit: onFormSubmit });
return (
<Form form={form}>
...
<button onClick={form.submit}>Submit</button>
</Form>
);
};
```
Great! We have our first working form. No state to worry about, just a simple declarative way to build our fields.
Those of you who are attentive might have noticed that the above form _does_ render the fields in the UI although we said earlier that the core of the form lib is not responsible for any UI rendering. This is because the `<UseField />` has a fallback mechanism to render an `<input type="text" />` and hook to the field `value` and `onChange`. Unless you have styled your `input` elements and don't require other field types like `checkbox` or `select`, you will probably want to <DocLink id="formLibExampleStyleFields" text="customize"/> how the the `<UseField />` renders. We will see that in a future section.

View file

@ -0,0 +1,85 @@
---
id: formLibCoreUseArray
slug: /form-lib/core/use-array
title: <UseArray />
summary: The perfect companion to generate dynamic fields
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
Use the `<UseArray />` component whenever you want to let the user add or remove fields in your form. Those fields will always be part of an array. Either an array of _values_, or an array of _objects_.
If you need those dynamic fields to be returned differently, you can [use a `serializer`](use_field.md#serializer) to transform the array.
There are no limits to how nested arrays and fields can be.
```js
// You can simply generate a list of string values
const myFormData = {
tags: ['value1', 'value2', 'value3'];
};
// Or you can generate more complex objects
const myFormData = {
book: { // path: "book"
title: 'My book', // path: "book.title"
tags: [ // path: "book.tags"
{
label: 'Tag 1', // path: "book.tags[0].label
value: 'tag_1', // path: "book.tags[0].value
colors: [ // path: "book.tags[0].colors
'green', // path: "book.tags[0].colors[0]
'yellow' // path: "book.tags[0].colors[1]
]
}
]
}
}
```
**Note:** Have a <DocLink id="formLibExampleDynamicFields" text="look at the examples" /> on how to use `<UseArray />`.
This component accepts the following props (the only required prop is the `path`).
## Props
### path (required)
**Type:** `string`
The array path. It can be any valid [`lodash.set()` path](https://lodash.com/docs/#set).
### initialNumberOfItems
**Type:** `number`
**Default:** `1`
Define the number of items you want to have by default in the array. It is only used when there are no `defaultValue` found for the array. If there is a default value found, the number of items will be the length of that array.
Those items are not fields yet, they are objects that you will receive back in the child function.
### validations
**Type:** `FieldConfig['validations']`
Array of validations to run whenever an item is added or removed. This is <DocLink id="formLibCoreUseField" section="validations" text="the same `validations` configuration" /> that you define on the field config. The `value` that you receive is the `items` passed down to the child function (see below).
### readDefaultValueOnForm
**Type:** `boolean`
**Default:** `true`
Flag to indicate if you want to read the array value from <DocLink id="formLibCoreUseForm" section="defaultvalue" text="the form `defaultValue` object" />.
### children
**Type:** `(formFieldArray: FormArrayField) => JSX.Element`
The children of `<UseArray />` is a function child which receives the form array field. You are then responsible to return a JSX element from that function.
The `FormArrayField` that you get back in the function has the following properties:
* `items` - The array items you can iterate on
* `error` - A string with possible validation error messages concatenated. It is `null` if there are no errors
* `addItem()` - Handler to add a new item to the array
* `removeItem(id: number)` - Handler to remove an item from the array
* `moveItem(source: number, destination: number)` - Handler to reorder items
* `form` - The `FormHook` object

View file

@ -0,0 +1,397 @@
---
id: formLibCoreUseField
slug: /form-lib/core/use-field
title: <UseField />
summary: Drop it anywhere in your <Form /> and see the magic happen
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
To declare a field in the form you use the `<UseField />` component.
This component accepts the following props (the only required prop is the `path`).
## Props
### path (required)
**Type:** `string`
The field path. It can be any valid [`lodash.set()` path](https://lodash.com/docs/#set).
```js
<UseField path="user.name" />
<UseField path="user.email" />
<UseField path="city" />
// The above 3 fields will output the following object
{
user: {
name: 'John',
email: 'john@elastic.co',
},
city: 'Paris'
}
```
### defaultValue
**Type:** `any`
An optional default value for the field. This will be the initial value of the field. The component is not controlled so updating this prop does not have any effect on the field.
**Note:** You can define the field `defaultValue` in different places (<DocLink id="formLibCoreDefaultValue" text="see their differences here" />).
### config
**Type:** `FieldConfig<FormInterface, ValueType>`
The field configuration.
**Note**: In some cases it makes more sense to declare all your form fields configuration inside a <DocLink id="formLibCoreUseForm" section="schema" text="form schema" /> that you pass to the form. This will unclutter your JSX.
```js
// It is a good habit to keep the configuration outside the component
// as in most case it is static and so this will avoid unnecessary re-renders.
const nameConfig: FieldConfig<MyForm, string> = {
label: 'Name',
validations: [ ... ],
};
export const MyFormComponent = {
const { form } = useForm(;)
return (
<Form form={form}>
<UseField path="name" config={nameConfig} />
</Form>
);
};
```
This configuration has the following parameters.
#### label
**Type:** `string`
A label for the field.
#### labelAppend
**Type:** `string | ReactNode`
A second label for the field.
When `<UseField />` is paired with one of <DocLink id="formLibHelpersComponents" text="the helper components" /> that wrap the EUI form fields, this prop is forwarded to the `<EuiFormRow />` `labelAppend` prop. As per [the EUI docs](https://elastic.github.io/eui/#/forms/form-layouts): _it adds an extra node to the right of the form label without being contained inside the form label. Good for things like documentation links._
#### helpText
**Type:** `string | ReactNode`
A help text for the field.
#### type
**Type:** `string`
Specify a type for your field. It can be any string, but if you decide to use the `<Field />` helper component, then defining one of the `FIELD_TYPES` will automatically render the correct field for you.
```js
import { Form, UseField, Field, FIELD_TYPES } from '<path-to-form-lib>';
const nameConfig = {
label: 'Name',
type: FIELD_TYPES.TEXT,
};
const showSettingsConfig = {
label: 'Show advanced settings',
type: FIELD_TYPES.TOGGLE,
};
export const MyFormComponent = () => {
const { form } = useForm();
// We use the same "Field" component to render both fields
// but as their "type" differs, they will render different UI fields.
return (
<Form form={form}>
<UseField path="name" config={nameConfig} component={Field} />
<UseField path="showSettings" config={showSettingsConfig} component={Field} />
</Form>
);
};
```
The above example could be written a bit simpler with a form schema and <DocLink id="formLibCoreUseField" section="getusefield" text="the getUseField() helper" />.
```js
import { Form, getUseField, Field, FIELD_TYPES } from '<path-to-form-lib>';
const schema = {
name: {
label: 'Name',
type: FIELD_TYPES.TEXT,
},
showSettings: {
label: 'Show advanced settings',
type: FIELD_TYPES.TOGGLE,
}
};
const UseField = getUseField({ component: Field });
export const MyFormComponent = () => {
const { form } = useForm({ schema });
return (
<Form form={form}>
<UseField path="name" />
<UseField path="showSettings" />
</Form>
);
};
```
#### validations
**Type:** `ValidationConfig[]`
An array of validation to run against the field value. Although it would be possible to have a single validation that does multiple checks, it often makes the code clearer to have single purpose validation that return a single error if there is one.
If any of the validation fails, the other validations don't run unless <DocLink id="formLibCoreUseField" section="exitonfail" text="the `exitOnFail` parameter" /> (`false` by default) is set to `true`.
**Note:** There are already many <DocLink id="formLibHelpersValidators" text="reusable field validators" />. Check if there isn't already one for your use case before writing your own.
The `ValidationConfig` accepts the following parameters:
##### validator (Required)
**Type:** `ValidationFunc`
**Arguments:** `data: ValidationFuncArg`
**Returns:** `ValidationError | void | Promise<ValidationError> | Promise<void>`
A validator function to execute. It can be synchronous or asynchronous.
**Note:** Have a look a <DocLink id="formLibExampleValidation" text="the validation examples" /> for different use cases.
This function receives a data argument with the following properties:
* `value` - The field value
* `path` - The field path being validated
* `form.getFormData` - A handler to build the form data
* `form.getFields` - A handler to access the form fields
* `formData` - The raw form data
* `errors` - An array of any previous validation errors
##### type
**Type:** `string`
A specific type for the validation. <DocLink id="formLibExampleValidation" section="validating-arrays-of-items" text="See an example of typed validation when validating an array of items" />.
##### isBlocking
**Type:** `boolean`
**Default:** `true`
By default all validation are blockers, which means that if they fail, the field `isValid` state is set to `false`. There might be some cases where you don't want the form to be invalid when a fied validation fails.
For example: when we add an item to the ComboBox array, we don't want to block the UI and set the field (array) as invalid if the item is invalid. We won't add the item to the array but the field is still valid. For that we will pass `isBlocking: false` to the validation on the array item.
##### exitOnFail
**Type:** `boolean`
**Default:** `true`
By default, when any of the validation fails, the following validation are not executed. If you still want to execute the following validation(s), set the `exitOnFail` to `false`.
#### deserializer
**Type:** `SerializerFunc`
If the type of a field value differs from the type provided as `defaultValue` you can use a `deserializer` to transform the value. This handler is executedo once to initialize the field `value` state.
```js
// The country field select options
const countries = [{
value: 'us',
label: 'USA',
}, {
value: 'es',
label: 'Spain',
}];
const countryConfig = {
label: 'Country',
deserializer: (defaultValue: string) => {
// We return the object our field expects.
return countries.find(country => country.value === defaultValue);
}
};
export const MyFormComponent = () => {
const fetchedData = {
// The server returns a string, but our field expects
// an object with a "value" and "label" property.
country: 'es',
};
const { form } = useForm({ defaultValue: fetchedData });
return (
<Form form={form}>
<UseField path="country" config={countryConfig} component={SelectField} />
</Form>
)
}
```
#### serializer
**Type:** `SerializerFunc`
This is the reverse process of the `deserializer`. It is only executed when getting the form data (with `form.submit()` or `form.getFormData()`).
```js
// Continuing the example above
const countryConfig = {
label: 'Country',
deserializer: (defaultValue: string) => {
return countries.find(country => country.value === defaultValue);
},
serializer: (fieldValue: { value: string; label: string }) => {
return fieldValue.value;
},
};
```
#### formatters
**Type:** `FormatterFunc[]`
If you need to format the field value each time it changes you can use a formatter for that. You can provide as many formatters as needed.
**Note:** Only use formatters when you need to change visually how the field value appears in the UI. If you only need the transformed value when submitting the form, it is better to use a `serializer` for that.
Each `FormatterFunc` receives 2 arguments:
* `value` - The field value
* `formData` - The form data
```js
const nameConfig = {
formatters: [(value: string) => {
// Capitalize the field value on each key stroke
return value.toUppercase();
}],
};
```
#### fieldsToValidateOnChange
**Type:** `string[]` - An array of field paths
**Default:** `[<current-field-path>]`
By default when a field value changes, it is the only field that is validated. In some cases you might also want to run the validation on another field that is linked.
Don't forget to include the current field path if you update this settings, unless you specifically do not want to run the validations on the current field.
```js
const field1Config = {
fieldsToValidateOnChange: ['field1', 'field2'],
};
const field2Config = {
fieldsToValidateOnChange: ['field2', 'field1'],
};
```
#### valueChangeDebounceTime
**Type:** `number`
The minimum time to update the `isChanging` field state. <DocLink id="formLibCoreUseForm" section="valuechangedebouncetime" text="Read more about this setting here"/>.
### component
**Type:** `FunctionComponent`
The component to render. This component will receive the `field` hook object as props plus any other props that you pass in `componentProps` (see below).
**Note:** You can see examples on how this prop is used in <DocLink id="formLibExampleStyleFields" section="using-the-component-prop" text="the `Style fields` example page" />.
### componentProps
**Type:** `{ [prop: string]: any }`
If you provide a `component` you can pass here any prop you want to forward to this component.
### readDefaultValueOnForm
**Type:** `boolean`
**Default:** true
By default if you don't provide a `defaultValue` prop to `<UseField />`, it will try to read the default value on <DocLink id="formLibCoreUseForm" section="defaultvalue" text="the form `defaultValue` object" />. If you want to prevent this behaviour you can set `readDefaultValueOnForm` to false. This can be usefull for dynamic fields, as <DocLink id="formLibExampleDynamicFields" text="you can see in the examples" />.
### onChange
**Type:** `(value:T) => void`
With this handler you can listen to the field value changes. <DocLink id="formLibExampleListeningToChanges" section="using-the-onchange-handler" text="See the example" /> in the "Listening to changes" page.
### onError
**Type:** `(errors: string[] | null) => void`
Callback that will be called whenever the field validity changes. When `null` is returned it means that the field is valid.
### children
**Type:** `(field: FieldHook<T>) => JSX.Element`
The (optional) children of `<UseField />` is a function child which receives the <DocLink id="formLibCoreFieldHook" text="field hook" />. You are then responsible to return a JSX element from that function.
## Helpers
### `getUseField()`
**Arguments:** `props: UseFieldProps`
In some cases you might find yourself declaring the exact same prop on `<UseField />` for all your fields. (e.g. using the [the `Field` component](../helpers/components#field) everywhere).
You can use the `getUseField` helper to get a `<UseField />` component with predefined props values.
```js
const UseField = getUseField({ component: Field });
const MyFormComponent = () => {
...
return (
<Form form={form}>
{/*You now can use it in your JSX without specifying the component anymore */}
<UseField path="name" />
</Form>
);
};
```
## Typescript value type
You can provide the value type (`unknown` by default) on the component.
```js
<UseField<string> path="name" defaultValue="mustBeAString" />
```
This has implication on the field config provided that has to have the same type.
```js
const nameConfig:FieldConfig<MyForm, string> = { ... };
<UseField<string> path="name" config={nameConfig} />
```

View file

@ -0,0 +1,65 @@
---
id: formLibCoreUseFormData
slug: /form-lib/core/use-form-data
title: useFormData()
summary: Get fields value updates from anywhere
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
**Returns:** `[rawFormData, () => T]`
Use the `useFormData` hook to access and react to form field value changes. The hook accepts an optional options object.
Have a look at the examples on how to use this hook in <DocLink id="formLibExampleListeningToChanges" text="the 'Listening to changes' page" />.
## Options
### form
**Type:** `FormHook`
The form hook object. It is only required to provide the form hook object in your **root form component**.
```js
const RootFormComponent = () => {
// root form component, where the form object is declared
const { form } = useForm();
const [formData] = useFormData({ form });
return (
<Form form={form}>
<ChildComponent />
</Form>
);
};
const ChildComponent = () => {
const [formData] = useFormData(); // no need to provide the form object
return (
<div>...</div>
);
};
```
### watch
**Type:** `string | string[]`
This option lets you define which field(s) to get updates from. If you don't specify a `watch` option, you will get updates when any form field changes. This will trigger a re-render of your component. If you want to only get update when a specific field changes you can pass it in the `watch`.
```js
// Only get update whenever the "type" field changes
const [{ type }] = useFormData({ watch: 'type' });
// Only get update whenever either the "type" or the "subType" field changes
const [{ type, subType }] = useFormData({ watch: ['type', 'subType'] });
```
## Return
As you have noticed, you get back an array from the hook. The first element of the array is form data and the second argument is a handler to get the **serialized** form data if needed.
```js
const [formData, getSerializedData] = useFormData();
```

View file

@ -0,0 +1,263 @@
---
id: formLibCoreUseForm
slug: /form-lib/core/use-form
title: useForm()
summary: The only hook you'll need to declare a new form
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
**Returns:** [`FormHook`](form_hook.md)
Use the `useForm` hook to declare a new form object. As we have seen in the <DocLink id="formLibCoreFundamentals" text="fundamentals"/>, you can use it without any additional configuration. It does accept an optional `config` object with the following configuration (all parameters are optional).
## Configuration
### onSubmit(data, isValid)
**Arguments:** `data: T, isValid: boolean`
**Returns:** `Promise<void>`
The `onSubmit` handler is executed when calling `form.submit()`. It receives the form data and a boolean for the validity of the form.
When the form is submitted its `isSubmitting` state will be set to `true` and then back to `false` after the `onSubmit` handler has finished running. This can be useful to update the state of the submit button while saving the form to the server for example.
```js
interface MyFormData {
name: string;
}
const onFormSubmit = async (data: MyFormData, isValid: boolean): Promise<void> => {
// "form.isSubmitting" is set to "true"
if (!isValid) {
// Maybe show a callout
return;
}
// Do anything with the data
await myApiService.createResource(data);
// "form.isSubmitting" is set to "false".
}
const { form } = useForm<MyFormData>({ onSubmit: onFormSubmit });
// JSX
<button disabled={form.isSubmitting} onClick{form.submit}>Send form</button>
```
### defaultValue
**Type:** `Record<string, any>`
The `defaultValue` is an object that you provide to give the initial value for your fields.
**Note:** There are multiple places where you can define the default value of a field, <DocLink id="formLibCoreDefaultValue" text="read the difference between them"/>.
```js
const fetchedData = { firstName: 'John' };
const { form } = useForm({ defaultValue: fetchedData });
```
### schema
**Type:** `Record<string, FieldConfig>`
Instead of manually providing a `config` object to each `<UseField />`, in some cases it is more convenient to provide a schema to the form with the fields configuration at the desired paths.
```js
interface MyForm {
user: {
firstName: string;
lastName: string;
}
}
const schema: Schema<MyForm> {
user: {
firstName: {
defaultValue: '',
... // other config
},
lastName: {
defaultValue: '',
...
},
isAdmin: {
defaultValue: false,
}
}
};
export const MyComponent = () => {
const { form } = useForm<MyForm>({ schema });
// No need to provide the "config" prop on each field,
// it will be read from the schema
return (
<Form form={form}>
<UseField path="user.firstName" />
<UseField path="user.lastName" />
<UseField path="user.isAdmin" />
</Form>
);
}
```
### deserializer
When you provide a `defaultValue` to the form, you might want to parse the object and modify it (e.g. add an extra field just for the UI). You would use a `deserializer` to do that. This handler receives the `defaultValue` provided and return a new object with updated fields default values.
**Note:** It is recommended to keep this pure function _outside_ your component and not declare it inline on the hook.
```js
import { Form, useForm, useFormData, Field, FIELD_TYPES, FormDataProvider } from '<path-to-form-lib>';
// Data coming from the server
const fetchedData = {
name: 'John',
address: {
street: 'El Camino Real #350'
}
}
// We want to have a toggle in the UI to display the address _if_ there is one.
// Otherwise the toggle value is "false" and no address is displayed.
const deserializer = (defaultValue) => {
return {
...defaultValue,
// We add an extra toggle field
showAddress: defaultValue.hasOwnProperty('address'),
};
}
export const MyComponent = ({ fetchedData }: Props) => {
const { form } = useForm({
defaultValue: fetchedData,
deserializer
});
const [{ showAddress }] = useFormData({ form, watch: 'showAddress' });
// We can now use our "showAddress" internal field in the UI
return (
<Form form={form}>
<UseField path="name" config={{ type: FIELD_TYPES.TEXT }} component={Field} />
<UseField path="showAddress" config={{ type: FIELD_TYPES.TOGGLE }} component={Field} />
{/* Show the street address when the toggle is "true" */}
{showAddress ? <UseField path="address.street" /> : null}
<button onClick={form.submit}>Submit</button>
</Form>
)
}
```
### serializer
Serializer is the inverse process of the deserializer. It is executed when we build the form data (when calling `form.submit()` for example).
**Note:** As with the `deserializer`, it is recommended to keep this pure function _outside_ your component and not declare it inline on the hook.
If we run the example above for the `deserializer`, and we click on the "Submit" button, we would get this in the console
```
Form data: {
address: {
street: 'El Camino Real #350'
},
name: 'John',
showAddress: true
}
```
We don't want to surface the internal `showAddress` field. Let's use a `serializer` to remove it.
```js
const deserializer = (value) => {
...
};
// Remove the showAddress field from the outputted data
const serializer = (value) => {
const { showAddress, ...rest } = value;
return rest;
}
export const MyComponent = ({ fetchedData }: Props) => {
const { form } = useForm({
defaultValue: fetchedData,
deserializer,
serializer,
});
...
};
```
Much better, now when we submit the form, the internal UI fields are not leaked outside when building the form object.
### id
**Type:** `string`
You can optionally give an id to the form, that will be attached to the `form` object you receive. This can be useful for debugging purpose when you have multiple forms on the page.
### options
**Type:** `{ valueChangeDebounceTime?: number; stripEmptyFields?: boolean }`
#### valueChangeDebounceTime
**Type:** `number` (ms)
**Default:** 500
When a field value changes, for example when we hit a key inside a text field, its `isChangingValue` state is set to `true`. Then, after all the validations have run for the field, the `isChangingValue` state is back to `false`. The time it take between those two state changes depends on the time it takes to run the validations. If the validations are all synchronous, the time will be `0`. If there are some asynchronous validations, (e.g. making an HTTP request to validate the value on the server), the "value change" duration will be the time it takes to run all the async validations.
With this option, you can define the minimum time you'd like to have between the two state change, so the `isChangingValue` state will stay `true` for at least the amount of milliseconds defined here. This is useful for example if you want to display possible errors on the field after a minimum of time has passed since the last value change.
This setting **can be overriden** on a per-field basis, providing a `valueChangeDebounceTime` in its config object.
```js
const { form } = useForm({ options: { valueChangeDebounceTime: 300 } });
return (
<UseField<string> path="name">
{(field) => {
let isInvalid = false;
let errorMessage = null;
if (!field.isChangingValue) {
// Only update this derived state after 300ms of the last key stroke
isInvalid = field.errors.length > 0;
errorMessage = isInvalid ? field.errors[0].message : null;
}
return (
<div>
<input type="text" value={field.value} onChange={field.onChange} />
{isInvalid && <div>{errorMessage}</div>}
</div>
);
}}
</UseField>
);
```
#### stripEmptyFields
**Type:** `boolean`
**Default:** `true`
With this option you can decide if you want empty string value to be returned by the form.
```js
// stripEmptyFields: true (default)
{
"firstName": "John"
}
// stripEmptyFields: false
{
"firstName": "John",
"lastName": "",
"role": ""
}
```

View file

@ -0,0 +1,97 @@
---
id: formLibCoreUseMultiFields
slug: /form-lib/core/use-multi-fields
title: <UseMultiFields />
summary: Because sometimes you need more than one field
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
You might find yourself at some point wanting to hook multiple fields to a component because that component accepts multiple values. In that case you will have to nest multiple `<UseField />` with their child function, which is not very elegant.
```js
<UseField path="maxValue">
{maxValueField => {
return (
<UseField path="minValue">
{minValueField => {
return (
<EuiDualRange
min={0}
max={100}
value={[minValueField.value, maxValueField.value]}
onChange={([minValue, maxValue]) => {
minValueField.setValue(minValue);
maxValueField.setValue(maxValue);
}}
/>
)
}}
</UseField>
)
}}
</UseField>
```
You can use `<UseMultiField />` to provide any number of fields and you will get them back in a single child function.
```js
const fields = {
min: {
// Any prop you would normally pass to <UseField />
path: 'minValue',
config: {
...
}
},
max: {
path: 'maxValue',
},
};
<UseMultiField fields={fields}>
{({ min, max }) => {
return (
<EuiDualRange
min={0}
max={100}
value={[min.value, max.value]}
onChange={([minValue, maxValue]) => {
min.setValue(minValue);
max.setValue(maxValue);
}}
/>
);
}}
</UseMultiField>
```
## Props
### fields (required)
**Type:** `{ [fieldId: string]: UseFieldProps }`
A map of field id to `<UseField />` props. The id does not have to match the field path, it will simply help you identify the fields that you get back in the child function.
### children
**Type:** `(fields: { fieldId: string: FieldHook }) => JSX.Element`
The children of `<UseMultiField />` is a function child which receives a map of field id to FieldHook. You are then responsible to return a JSX element from that function.
## Typescript value type
You can provide the field value type for each field (`unknown` by default) on the component.
```js
interface Fields {
min: number;
max: number;
}
// You are then required to provide those exact 2 fields in the "fields" prop
<UseMultiField<Fields> fields={{ min: { ... }, max: { ... } }}>
...
</UseMultiField>
```

View file

@ -0,0 +1,276 @@
---
id: formLibExampleDynamicFields
slug: /form-lib/examples/dynamic-fields
title: Dynamic fields
summary: Let the user add any number of fields on the fly
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
## Basic
Dynamic fields are fields that the user can add or remove. Those fields will end up in an array of _values_ or an array of _objects_, it's up to you. To work with dynamic fields in your form you use the <DocLink id="formLibCoreUseArray" text="<UseArray />"/> component.
Let's imagine a form that lets a user enter dynamic items to a list.
```js
export const DynamicFields = () => {
const todoList = {
items: [
{
title: 'Title 1',
subTitle: 'Subtitle 1',
},
{
title: 'Title 2',
subTitle: 'Subtitle 2',
},
],
};
const { form } = useForm({ defaultValue: todoList });
const submitForm = () => {
console.log(form.getFormData());
};
return (
<Form form={form}>
<UseArray path="items">
{({ items, addItem, removeItem }) => {
return (
<>
{items.map((item) => (
<EuiFlexGroup key={item.id}>
<EuiFlexItem>
<UseField
path={`${item.path}.title`}
config={{ label: 'Title' }}
component={TextField}
// Make sure to add this prop otherwise when you delete
// a row and add a new one, the stale values will appear
readDefaultValueOnForm={!item.isNew}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path={`${item.path}.subTitle`}
config={{ label: 'Subtitle' }}
component={TextField}
readDefaultValueOnForm={!item.isNew}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
onClick={() => removeItem(item.id)}
iconType="minusInCircle"
aria-label="Remove item"
style={{ marginTop: '28px' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
))}
<EuiButtonEmpty iconType="plusInCircle" onClick={addItem}>
Add item
</EuiButtonEmpty>
<EuiSpacer />
</>
);
}}
</UseArray>
<EuiSpacer />
<EuiButton onClick={submitForm} fill>
Submit
</EuiButton>
</Form>
);
};
```
## Validation
If you need to validate the number of items in the array, you can provide a `validations` prop to the `<UseArray />`. If, for example, we require at least one item to be added to the list, we can either:
* Hide the "Remove" button when there is only one item
* Add a `validations` prop
The first one is easy, let's look at the second option:
```js
const itemsValidations = [
{
validator: ({ value }: { value: Array<{ title: string; subtitle: string }> }) => {
if (value.length === 0) {
return {
message: 'You need to add at least one item',
};
}
},
},
];
const { emptyField } = fieldValidators;
const textFieldValidations = [{ validator: emptyField("The field can't be empty.") }];
export const DynamicFieldsValidation = () => {
const { form } = useForm();
const submitForm = async () => {
const { isValid, data } = await form.submit();
if (isValid) {
console.log(data);
}
};
return (
<Form form={form}>
<UseArray path="items" validations={itemsValidations}>
{({ items, addItem, removeItem, error, form: { isSubmitted } }) => {
const isInvalid = error !== null && isSubmitted;
return (
<>
<EuiFormRow label="Todo items" error={error} isInvalid={isInvalid} fullWidth>
<>
{items.map((item) => (
<EuiFlexGroup key={item.id}>
<EuiFlexItem>
<UseField
path={`${item.path}.title`}
config={{ label: 'Title', validations: textFieldValidations }}
component={TextField}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path={`${item.path}.subtitle`}
config={{ label: 'Subtitle', validations: textFieldValidations }}
component={TextField}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
onClick={() => removeItem(item.id)}
iconType="minusInCircle"
aria-label="Remove item"
style={{ marginTop: '28px' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
))}
</>
</EuiFormRow>
<EuiButtonEmpty iconType="plusInCircle" onClick={addItem}>
Add item
</EuiButtonEmpty>
<EuiSpacer />
</>
);
}}
</UseArray>
<EuiSpacer />
<EuiButton onClick={submitForm} fill disabled={form.isSubmitted && form.isValid === false}>
Submit
</EuiButton>
</Form>
);
};
```
## Reorder array items
```js
export const DynamicFieldsReorder = () => {
const { form } = useForm();
const submitForm = async () => {
const { data } = await form.submit();
console.log(data);
};
return (
<Form form={form}>
<UseArray path="items">
{({ items, addItem, removeItem, moveItem }) => {
const onDragEnd = ({ source, destination }: DropResult) => {
if (source && destination) {
moveItem(source.index, destination.index);
}
};
return (
<>
<EuiFormRow label="Todo items" fullWidth>
<EuiDragDropContext onDragEnd={onDragEnd}>
<EuiDroppable droppableId="1">
{items.map((item, idx) => {
return (
<EuiDraggable
spacing="none"
draggableId={String(item.id)}
index={idx}
key={item.id}
>
{(provided) => {
return (
<EuiFlexGroup key={item.id}>
<EuiFlexItem grow={false}>
<div {...provided.dragHandleProps} style={{ marginTop: '30px' }}>
<EuiIcon type="grab" />
</div>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path={`${item.path}.title`}
config={{ label: 'Title', validations: textFieldValidations }}
component={TextField}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path={`${item.path}.subtitle`}
config={{
label: 'Subtitle',
validations: textFieldValidations,
}}
component={TextField}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
onClick={() => removeItem(item.id)}
iconType="minusInCircle"
aria-label="Remove item"
style={{ marginTop: '28px' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}}
</EuiDraggable>
);
})}
</EuiDroppable>
</EuiDragDropContext>
</EuiFormRow>
<EuiButtonEmpty iconType="plusInCircle" onClick={addItem}>
Add item
</EuiButtonEmpty>
<EuiSpacer />
</>
);
}}
</UseArray>
<EuiSpacer />
<EuiButton onClick={submitForm} fill disabled={form.isSubmitted && form.isValid === false}>
Submit
</EuiButton>
</Form>
);
};
```

View file

@ -0,0 +1,167 @@
---
id: formLibExampleFieldsComposition
slug: /form-lib/examples/fields-composition
title: Fields composition
summary: Be DRY and compose your form
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
If your form does not have a fix set of fields (single interface) and you need to add/remove fields dynamically, you can leverage the power of field composition with the form lib. It let's you swap fields in your form whenever needed. Any field that **is not in the DOM** is automatically cleared when unmounting and its value won't be returned in the form data.
If you _do_ need to keep a field value, but hide the field in the UI, then you need to use CSS (`<div style={{ display: isVisible ? 'block' : 'none' }}>...</div>`)
Imagine you're building an app that lets people buy a car online. You want to build a form that lets the user select the model of the car (`sedan`, `golf cart`, `clown mobile`), and based on their selection you'll show a different form for configuring the selected model's options.
Those are the 3 car configurations that the form can output:
```js
// sedan
{
model: 'sedan',
used: true,
plate: 'UIES2021', // unique config for this car
};
// golf cart
{
model: 'golf_cart',
used: false,
forRent: true, // unique config for this car
};
// clown mobile
{
model: 'clown_mobile',
used: true,
miles: 1.0, // unique config for this car
}
```
Let's create one component for each car that will expose its unique parameter(s). Those components won't have to render the `model` and the `used` form fields as they are common to all three cars and we will put them at the root level of the form.
```js
// sedan_car.tsx
const plateConfig = {
label: 'Plate number',
};
export const SedanCar = () => {
return (
<>
<UseField path="plate" config={plateConfig} component={TextField} />
</>
);
};
```
```js
// golf_cart_car.tsx
const forRentConfig = {
label: 'The cart is for rent',
defaultValue: true,
};
export const GolfCartCar = () => {
return (
<>
<UseField path="forRent" config={forRentConfig} component={ToggleField} />
</>
);
};
```
```js
// clown_mobile_car.tsx
const milesConfig = {
label: 'Current miles',
defaultValue: 1.0,
serializer: parseFloat,
};
export const ClownMobileCar = () => {
return (
<>
<UseField path="miles" config={milesConfig} component={NumericField} />
</>
);
};
```
And finally, let's build our form which will swap those components according to the selected car `model`.
```js
import { UsedParameter } from './used_parameter';
import { SedanCar } from './sedan_car';
import { GolfCartCar } from './golf_cart_car';
import { ClownMobileCar } from './clown_mobile_car';
const modelToComponentMap: { [key: string]: React.FunctionComponent } = {
sedan: SedanCar,
golfCart: GolfCartCar,
clownMobile: ClownMobileCar,
};
// We create a schema so we don't need to manually add the config
// to the component through props
const formSchema = {
model: {
label: 'Car model',
defaultValue: 'sedan',
},
used: {
label: 'Car has been used',
defaultValue: false,
}
};
const modelOptions = [
{
text: 'sedan',
},
{
text: 'golfCart',
},
{
text: 'clownMobile',
},
];
export const CarConfigurator = () => {
const { form } = useForm({ schema: formSchema });
const [{ model }] = useFormData<{ model: string }>({ form, watch: 'model' });
const renderCarConfiguration = () => {
// Select the car configuration according to the chosen model.
const CarConfiguration = modelToComponentMap[model];
return <CarConfiguration />;
};
const submitForm = () => {
console.log(form.getFormData());
};
return (
<Form form={form}>
<UseField
path="model"
component={SelectField}
componentProps={{
euiFieldProps: { options: modelOptions },
}}
/>
<UseField path="used" component={ToggleField} />
{model !== undefined ? renderCarConfiguration() : null}
<EuiSpacer />
<EuiButton onClick={submitForm} fill>
Submit
</EuiButton>
</Form>
);
};
```

View file

@ -0,0 +1,214 @@
---
id: formLibExampleListeningToChanges
slug: /form-lib/examples/listening-to-changes
title: Listening to changes
summary: React to changes deep down the tree
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
## Basic
### Access the form data from the root component
You can access the form data by using the <DocLink id="formLibCoreUseFormData" text="useFormData()"/> hook. This hook has an optional `form` option that you only have to provide if you need to access the data in the **root** component.
```js
// From the root component (where the "form" is declared)
export const MyComponent = () => {
const { form } = useForm();
const [formData] = useFormData({ form });
return (
<Form form={form}>
<UseField path="firstName" config={{ label: 'First name' }} component={TextField} />
<UseField path="lastName" config={{ label: 'Last name' }} component={TextField} />
<EuiCode>{JSON.stringify(formData)}</EuiCode>
</Form>
);
};
```
### Access the form data from a child component
To access the form data from inside a child component you also use the `useFormData()` hook, but this time you don't need to provide the `form` as it is read from context.
```js
const FormFields = () => {
const [formData] = useFormData();
return (
<>
<UseField path="firstName" config={{ label: 'First name' }} component={TextField} />
<UseField path="lastName" config={{ label: 'Last name' }} component={TextField} />
<EuiCode>{JSON.stringify(formData)}</EuiCode>
</>
)
};
export const MyComponent = () => {
const { form } = useForm();
return (
<Form form={form}>
<FormFields />
</Form>
);
};
```
## Listen to specific form fields changes
In some cases you only want to listen to some field change and don't want to trigger a re-render of your component for every field value change in your form. You can specify a **watch** (`string | string[]`) parameter for that.
```js
export const ReactToSpecificFields = () => {
const { form } = useForm();
// Only listen for changes from the "showAddress" toggle
const [{ showAddress }] = useFormData({ form, watch: 'showAddress' });
return (
<Form form={form}>
{/* Changing the "name" field won't trigger a re-render */}
<UseField path="name" config={{ label: 'First name' }} component={TextField} />
<UseField
path="showAddress"
config={{ defaultValue: false, label: 'Show address' }}
component={ToggleField}
/>
{showAddress && (
<>
<p>800 W El Camino Real #350</p>
</>
)}
</Form>
);
};
```
## Using the `onChange` handler
Sometimes the good old `onChange` handler is all you need to react to a form field value change (instead of reading the form data and adding a `useEffect` to react to it).
```js
export const OnChangeHandler = () => {
const { form } = useForm();
const onNameChange = (value: string) => {
console.log(value);
};
return (
<Form form={form}>
<UseField
path="name"
config={{ label: 'Name' }}
component={TextField}
onChange={onNameChange}
/>
</Form>
);
};
```
## Forward the form state to a parent component
If your UX requires to submit the form in a parent component (e.g. because that's where your submit button is located), you will need a way to access the form validity and the form data outside your form component. Unless your parent component needs to be aware of every field value change in the form (which should rarely be needed), you don't want to use the `useFormData()` hook and forward the data from there. This would create unnecessary re-renders. Instead it is better to forward the `getFormData()` handler on the form.
This pattern is useful when, for example, the form is inside one of the steps of multi-step wizard and the button to go "next" is thus outside the scope of the component where the form is declared.
```js
interface MyForm {
name: string;
}
interface FormState {
isValid: boolean | undefined;
validate(): Promise<boolean>;
getData(): MyForm;
}
const schema: FormSchema<MyForm> = {
name: {
validations: [
{
validator: ({ value }) => {
if (value === 'John') {
return { message: `The username "John" already exists` };
}
},
},
],
},
};
interface Props {
defaultValue: MyForm;
onChange(formState: FormState): void;
}
const MyForm = ({ defaultValue, onChange }: Props) => {
const { form } = useForm<MyForm>({ defaultValue, schema });
const { isValid, validate, getFormData } = form;
// getFormData() is a stable reference that is not mutated when the form data change.
// This means that it does not trigger a re-render on each form data change.
useEffect(() => {
const updatedFormState = { isValid, validate, getData: getFormData };
// Forward the state to the parent
onChange(updatedFormState);
}, [onChange, isValid, validate, getFormData]);
return (
<Form form={form}>
<UseField path="name" component={TextField} />
</Form>
);
};
export const ForwardFormStateToParent = () => {
// This would probably come from the server
const formDefaultValue: MyForm = {
name: 'John',
};
// As the parent component does not know anything about the form until the form calls an onChange(),
// we initially set the validate() and getData() to return the default state.
const initialState = {
isValid: true,
validate: async () => true,
getData: () => formDefaultValue,
};
const [formState, setFormState] = useState<FormState>(initialState);
const sendForm = useCallback(async () => {
// The form isValid state will stay "undefined" until either:
// - all the fields are dirty
// - we call the form "validate()" or "submit()" methods
// This is why we first check if it is undefined and if it is, we call the validate() method
// which will validate **only** the fields that haven't been validated yet.
const isValid = formState.isValid ?? (await formState.validate());
if (!isValid) {
// Show a callout somewhere...
return;
}
console.log('Form data', formState.getData());
}, [formState]);
return (
<>
<h1>My form</h1>
<MyForm defaultValue={formDefaultValue} onChange={setFormState} />
<EuiButton color="primary" onClick={sendForm} disabled={formState.isValid === false} fill>
Submit
</EuiButton>
</>
);
};
```

View file

@ -0,0 +1,104 @@
---
id: formLibExampleSerializersDeserializers
slug: /form-lib/examples/serializers-deserializers
title: Serializers & Deserializers
summary: No need for a 1:1 map of your API with your form fields, be creative!
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
Forms help users edit data. This data is often persisted, for example saved in Elasticsearch. When it's persisted, the shape of the data typically reflects the concerns of the domain or the persistence medium. When it's edited in a form, the shape of the data reflects different concerns, such as UI state. Data is **deserialized** from its persisted shape to its form-editable shape and **serialized** from its form-editable shape to its persisted shape.
With that in mind, you can pass the following handlers to the form
* **deserializer**: A function that converts the persisted shape to the form-editable shape.
* **serializer**: A function that converts the form-editable shape to the persisted shape.
Let's see it through an example.
```js
// This is the persisted shape of our data
interface MyForm {
name: string;
customLabel: string;
}
// This is the internal fields we will need in our form
interface MyFormUI {
name: string;
customLabel: string;
showAdvancedSettings: boolean;
}
const formDeserializer = ({ name, customLabel }: MyForm): MyFormUI => {
// Show the advanced settings if a custom label is provided
const showAdvancedSettings = Boolean(customLabel);
return {
name,
customLabel,
showAdvancedSettings,
};
};
const formSerializer = ({ name, customLabel }: MyFormUI): MyForm => {
// We don't forward the "showAdvancedSettings" field
return { name, customLabel };
};
const schema: FormSchema<MyFormUI> = {
name: { label: 'Name' },
customLabel: { label: 'CustomLabel' },
showAdvancedSettings: {
label: 'Show advanced settings',
defaultValue: false,
},
};
export const SerializersAndDeserializers = () => {
// Data coming from the server
const fetchedData: MyForm = {
name: 'My resource',
customLabel: 'My custom label',
};
const { form } = useForm<MyForm, MyFormUI>({
defaultValue: fetchedData,
schema,
deserializer: formDeserializer,
serializer: formSerializer,
});
const [{ showAdvancedSettings }] = useFormData({
form,
watch: ['showAdvancedSettings'],
});
const submitForm = async () => {
const { isValid, data } = await form.submit();
if (isValid) {
console.log(data);
}
};
return (
<Form form={form}>
<UseField path="name" component={TextField} />
<UseField path="showAdvancedSettings" component={ToggleField} />
<EuiSpacer />
{/* We don't remove it from the DOM as we would lose the value entered in the field. */}
<div style={{ display: showAdvancedSettings ? 'block' : 'none' }}>
<UseField path="customLabel" component={TextField} />
</div>
<EuiSpacer />
<EuiButton onClick={submitForm} fill disabled={form.isSubmitted && form.isValid === false}>
Submit
</EuiButton>
</Form>
);
};
```

View file

@ -0,0 +1,66 @@
---
id: formLibExampleStyleFields
slug: /form-lib/examples/styles-fields
title: Style fields
summary: Customize your fields however you want
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
The `<UseField />` is a render prop component that returns a <DocLink id="formLibCoreFieldHook" text="Field hook"/>.
You can then connect its states and handlers to any UI.
```js
export const MyComponent = () => {
const { form } = useForm();
// Notice how we have typed the value of the field with <UseField<string> ...>
return (
<Form form={form}>
<UseField<string> path="firstname" config={{ label: 'First name' }}>
{(field) => {
const {
isChangingValue,
errors,
label,
helpText,
value,
onChange,
isValidating
} = field;
const isInvalid = !isChangingValue && errors.length > 0;
const errorMessage = !isChangingValue && errors.length ? errors[0].message : null;
return (
<EuiFormRow
label={label}
helpText={typeof helpText === 'function' ? helpText() : helpText}
error={errorMessage}
isInvalid={isInvalid}
>
<EuiFieldText
isInvalid={isInvalid}
value={value}
onChange={onChange}
isLoading={isValidating}
/>
</EuiFormRow>
);
}}
</UseField>
</Form>
);
};
```
## Using the `component` prop
The above example can be simplified by extracting the children into its own component and by using the `component` prop on `<UseField />`.
The component will receive the `field` hook as a prop and any other props you pass to `componentProps`.
```js
<UseField path="name" component={MyTextField} componentProps={{ foo: 'bar' }}>
```
**Note:** Before creating your own reusable component have a look at <DocLink id="formLibHelpersComponents" text="the helper components"/> which handle most of the form inputs of [the EUI framework](https://elastic.github.io/eui).

View file

@ -0,0 +1,274 @@
---
id: formLibExampleValidation
slug: /form-lib/examples/validation
title: Validation
summary: Don't let invalid data leak out of your form!
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
## Basic
```js
import React from 'react';
import {
useForm,
Form,
UseField,
FieldConfig,
} from '<path-to-form-lib>';
interface MyForm {
name: string;
}
const nameConfig: FieldConfig<MyForm, string> = {
validations: [
{
validator: ({ value }) => {
if (value.trim() === '') {
return {
message: 'The name cannot be empty.',
};
}
},
},
// ...
// You can add as many validations as you need.
// It is a good practice to keep validators single purposed,
// and compose them in the "validations" array.
// This way if any other field has the same validation we can easily
// copy it over or extract it and import it in multiple places.
],
};
export const MyComponent = () => {
const { form } = useForm<MyForm>();
return (
<Form form={form}>
<UseField<string> path="name" config={nameConfig}>
{(field) => {
const isInvalid = !field.isChangingValue && field.errors.length > 0;
const errorMessage = !isChangingValue && errors.length ? errors[0].message : null;
return (
<EuiFormRow
label={field.label}
helpText={typeof field.helpText === 'function' ? field.helpText() : helpText}
error={errorMessage}
isInvalid={isInvalid}
>
<EuiFieldText
isInvalid={isInvalid}
value={field.value}
onChange={field.onChange}
fullWidth
/>
</EuiFormRow>
);
}}
</UseField>
</Form>
);
};
```
**Note:** Before creating your own validator, verify that it does not exist already in our <DocLink id="formLibHelpersValidators" text="reusable field validators"/>.
## Asynchronous validation
You can mix synchronous and asynchronous validations. Although it is usually better to first declare the synchronous one(s), this way if any of those fail, the asynchronous validation is not executed.
In the example below, if you enter "bad" in the field, the asynchronous validation will fail.
```js
const nameConfig: FieldConfig<MyForm, string> = {
validations: [
{
validator: emptyField('The name cannot be empty,'),
},
{
validator: indexNameField(i18n),
},
{
validator: ({ value }) => {
return new Promise((resolve) => {
setTimeout(() => {
if (value === 'bad') {
resolve({ message: 'This index already exists' });
}
resolve();
}, 2000);
});
},
},
],
};
```
### Cancel asynchronous validation
If you need to cancel the previous asynchronous validation before calling the new one, you can do it by adding a `cancel()` handler to the Promise returned.
**Note:** Make sure **to not** use an `async/await` validator function when returning your Promise, or the `cancel` handler will be stripped out.
```js
const nameConfig: FieldConfig<MyForm, string> = {
validations: [
{
validator: ({ value }) => {
let isCanceled = false;
const promise: Promise<ValidationError | void | undefined> & { cancel?(): void } = new Promise((resolve) => {
setTimeout(() => {
if (isCanceled) {
console.log('This promise has been canceled, skipping');
return resolve();
}
if (value === 'bad') {
resolve({ message: 'This index already exists' });
}
resolve();
}, 2000);
});
promise.cancel = () => {
isCanceled = true;
};
return promise;
},
},
],
};
export const CancelAsyncValidation = () => {
const { form } = useForm<MyForm>();
return (
<Form form={form}>
<UseField<string> path="name" config={nameConfig}>
{(field) => {
const isInvalid = !field.isChangingValue && field.errors.length > 0;
return (
<>
<EuiFieldText
isInvalid={isInvalid}
value={field.value}
onChange={field.onChange}
isLoading={field.isValidating}
fullWidth
/>
{isInvalid && <div>{field.getErrorsMessages()}</div>}
</>
);
}}
</UseField>
</Form>
);
};
```
## Validating arrays of items
When validating an array of items we might have to handle **two types of validations**: one to make sure the array is valid (e.g. it is not empty or it contains X number of items), and another one to make sure that each item in the array is valid.
To solve that problem, you can give a `type` to a validation to distinguish between different validations.
Let's go through an example. Imagine that we have a form field to enter "tags" (an array of string). The array cannot be left empty and the tags cannot contain the "?" and "/" characters.
The form field `value` is an array of string, and the default validation(s) (those without a `type` defined) will run against this **array**. For the validation of the items we will use a **typed** validation.
**Note:** Typed validation are not executed when the field value changes, we need to manually validate the field with `field.validate(...)`.
```js
const tagsConfig: FieldConfig<MyForm, string[]> = {
defaultValue: [],
validations: [
// Validator for the Array
{ validator: emptyField('You need to add at least one tag') },
{
// Validator for the items
validator: containsCharsField({
message: ({ charsFound }) => {
return `Remove the char ${charsFound.join(', ')} from the field.`;
},
chars: ['?', '/'],
}),
// We give a custom type to this validation.
// This validation won't be executed when the field value changes
// (when items are added or removed from the array).
// This means that we will need to manually call:
// field.validate({ validationType: 'arrayItem' })
// to run this validation.
type: 'arrayItem',
},
],
};
export const MyComponent = () => {
const onSubmit: FormConfig['onSubmit'] = async (data, isValid) => {
console.log('Is form valid:', isValid);
console.log('Form data', data);
};
const { form } = useForm<MyForm>({ onSubmit });
return (
<Form form={form}>
<UseField<string[]> path="tags" config={tagsConfig}>
{(field) => {
// Check for error messages on **both** the default validation and the "arrayItem" type
const errorMessage =
field.getErrorsMessages() ?? field.getErrorsMessages({ validationType: 'arrayItem' });
const onCreateOption = (value: string) => {
const { isValid } = field.validate({
value: value,
validationType: 'arrayItem', // Validate **only** this validation type against the value provided
}) as { isValid: boolean };
if (!isValid) {
// Reject the user's input.
return false;
}
// Add the tag to the array
field.setValue([...field.value, value]);
};
const onChange = (options: EuiComboBoxOptionOption[]) => {
field.setValue(options.map((option) => option.label));
};
const onSearchChange = (value: string) => {
if (value !== undefined) {
// Clear immediately the "arrayItem" validation type
field.clearErrors('arrayItem');
}
};
return (
<>
<EuiComboBox
noSuggestions
placeholder="Type and then hit ENTER"
selectedOptions={field.value.map((v) => ({ label: v }))}
onCreateOption={onCreateOption}
onChange={onChange}
onSearchChange={onSearchChange}
fullWidth
/>
{!field.isValid && <div>{errorMessage}</div>}
<button onClick={form.submit}>Submit</button>
</>
);
}}
</UseField>
</Form>
);
};
```
Great, but that's **a lot** of code for a simple tags field input. Fortunatelly the `<ComboBoxField />` helper component takes care of all the heavy lifting for us. <DocLink id="formLibHelpersComponents" section="comboboxfield" text="Have a look at the example" />.

View file

@ -0,0 +1,172 @@
---
id: formLibHelpersComponents
slug: /form-lib/helpers/components
title: Components
summary: Build complex forms the easy way
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
The core of the form lib is UI agnostic. It can be used with any React UI library to render the form fields.
At Elastic we use [the EUI framework](https://elastic.github.io/eui). We created components that connect our `FieldHook` to the `<EuiFormRow/>` and its corresponding EUI field.
You can import these components and pass them to the `component` prop on your `<UseField />`.
```js
import { Form, useForm, UseField, TextField, ToggleField } from '<path-to-form-lib>';
export const MyFormComponent = () => {
const { form } = useForm();
return (
<Form form={form}>
<UseField path="name" component={TextField} />
<UseField path="isAdmin" component={ToggleField} />
</Form>
);
};
```
As you can see it is very straightforward. If there are any validation error(s) on those fields, they will be correctly set on the underlying `<EuiFormRow/>`, as well as the field `value`, `onChange` handler, label, helpText...
## Fields components
This is the list of component we currently have. This list might grow in the future if we see the need to support additional fields.
* TextField
* TextAreaField
* NumericField
* CheckBoxField
* ToggleField
* ComboBoxField<sup>*</sup>
* JsonEditorField
* SelectField
* SuperSelectField
* MultiSelectField
* RadioGroupField
* RangeField
(*) Currently the `<ComboBoxField />` only support the free form entry of items (e.g a list of "tags" that the user enters). This means that it does not work (yet) **with predefined selections** to chose from.
## `euiFieldProps`
Those helper components have been set to a default state that cover most of our use cases. You can override those defaults by passing new props to the `euiFieldProps`.
```js
<UseField
path="name"
component={TextField}
componentProps={{
euiFieldProps: {
fullWidth. false,
// ... any other <EuiFieldText /> prop override
}
}}
/>
```
## `Field`
There is a special `<Field />` component that you can use if you prefer. If you use this component, it will check <DocLink id="formLibCoreUseField" section="type" text="the field `type` configuration" /> and map to the corresponding component in the list above. If the type does not match any known component, a `<TextField />` component is rendered.
It is recommended to use the available `FIELD_TYPES` constant to indicate the type of a field in the `FieldConfig`.
```js
const schema: FormSchema = {
name: {
label: 'Name',
type: FIELD_TYPES.TEXT
},
isAdmin: {
label: 'User is admin',
type: FIELD_TYPES.CHECKBOX,
},
country: {
label: 'Country,
type: FIELD_TYPES.SELECT,
}
};
export const MyFormComponent = () => {
const { form } = useForm({ schema });
// You now can use the <Field /> component everywhere
return (
<Form form={form}>
<UseField path="name" component={Field} />
<UseField path="isAdmin" component={Field} />
<UseField path="country" component={Field} />
</Form>
);
};
```
The above example can be simplified one step further with <DocLink id="formLibCoreUseField" section="getusefield" text="the getUseField() helper" />.
```js
const schema: FormSchema = {
name: {
label: 'Name',
type: FIELD_TYPES.TEXT
},
...
};
const UseField = getUseField({ prop: Field });
// Nice and tidy form component :)
export const MyFormComponent = () => {
const { form } = useForm({ schema });
return (
<Form form={form}>
<UseField path="name" />
<UseField path="isAdmin" />
<UseField path="country" />
</Form>
);
};
```
## Examples
### ComboBoxField
The ComboBox has the particualrity of sometimes requiring **two validations**. One for the array and one for the items of the array. In the example below you can see how easy it is to generate an array of tags (`string[]`) in your form thanks to the `<ComboBoxField />` helper component.
```js
const tagsConfig: FieldConfig<MyForm, string[]> = {
defaultValue: [],
validations: [
// Validate that the array is not empty
{ validator: emptyField('You need to add at least one tag')},
{
// Validate each item about to be added to the combo box
validator: containsCharsField({
message: ({ charsFound }) => {
return `Remove the char ${charsFound.join(', ')} from the field.`;
},
chars: ['?', '/'],
}),
// We use a typed validation to validate the array items
// Make sure to use the "ARRAY_ITEM" constant
type: VALIDATION_TYPES.ARRAY_ITEM,
},
],
};
export const ValidationWithTypeComboBoxField = () => {
const onSubmit: FormConfig['onSubmit'] = async (data, isValid) => {
console.log('Is form valid:', isValid);
console.log('Form data', data);
};
const { form } = useForm<MyForm>({ onSubmit });
return (
<Form form={form}>
<UseField<string[]> path="tags" config={tagsConfig} component={ComboBoxField} />
<button onClick={form.submit}>Submit</button>
</Form>
);
};
```

View file

@ -0,0 +1,46 @@
---
id: formLibHelpersValidators
slug: /form-lib/helpers/validators
title: Validators
summary: Build complex forms the easy way
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
As you have seen in the `<UseField />` configuration, the validations are objects with <DocLink id="formLibCoreUseField" section="validator-required" text="a required `validator` function" /> attached to them.
After building many forms, we have realized that we are often doing the same validation on a field: is the field empty? does it contain a character not allowed?, does it start with an invalid character? is it valid JSON? ...
So instead of reinventing the wheel on each form we have exported to most common validators as reusable function that you can use directly in your field validations. Some validator might expose directly the handler to validate, some others expose a function that you need to call with some parameter and you will receive the validator back.
```js
import { fieldValidators } from '<path-to-form-lib>';
const { emptyField } = fieldValidators;
// Some validator expose a function that you need to call to receive the validator handler
const nameConfig: FieldConfig<string> = {
validations: [{
validator: emptyField('Your custom error message'),
}, {
validator: containsCharsField({
chars: ' ',
message: 'Spaces are not allowed in a component template name.',
})
}],
};
```
We have validators for valid
* index pattern name
* JSON
* URL
* number
* string start with char
* string contains char
* ...
Before your write your own validator, check (thanks to Typescript suggestions in your IDE) what is already exposed from the `fieldValidators` object.
And if need to build your own validator and you think that it is common enough for other forms, make a contribution to the form lib and open a PR to add it to our list!

View file

@ -0,0 +1,30 @@
---
id: formLibWelcome
slug: /form-lib/welcome
title: Welcome
summary: Build complex forms the easy way
tags: ['forms', 'kibana', 'dev']
date: 2021-04-14
---
## Presentation
The form library helps us build forms efficiently by providing a system whose main task is (1) to abstract away the state management of fields values and validity and (2) running validations on the fields when their values change.
The system is composed of **three parts**:
* <DocLink id="formLibCoreFundamentals" text="The core"/>
* <DocLink id="formLibHelpersComponents" text="Fields components"/>
* <DocLink id="formLibHelpersValidators" text="Validators"/>
## Motivation
In the Elasticsearch UI team we build many forms. Many many forms! :blush: For each of them, we used to manually declare the form state, write validation functions, call them on certain events and then update the form state. We were basically re-inventing the wheel for each new form we built. It took our precious dev time to re-think the approach each time, but even more problematic: it meant that each of our form was built slightly differently. Maintaining those forms meant that we needed to remember how the state was being updated on a specific form and how its validation worked. This was far from efficient...
We needed a system in place that took care of the repetitive task of managing a form state and validating its value, so we could dedicate more time doing what we love: **build amazing UX for our users!**.
The form lib was born.
## When shoud I use the form lib?
As soon as you have a form with 3+ fields and some validation that you need to run on any of those fields, the form lib can help you reduce the boilerplate and the time to get your form running. Of course, the more you use it, the more addicted you will get! :smile:

View file

@ -35,7 +35,6 @@
"@kbn/test": "link:../packages/kbn-test"
},
"dependencies": {
"@elastic/safer-lodash-set": "link:../packages/elastic-safer-lodash-set",
"@kbn/i18n": "link:../packages/kbn-i18n",
"@kbn/interpreter": "link:../packages/kbn-interpreter",
"@kbn/ui-framework": "link:../packages/kbn-ui-framework"

View file

@ -78,25 +78,6 @@ describe('loadAlertType', () => {
expect(await loadAlertType({ http, id: 'test-another' })).toEqual(alertType);
});
test('should throw if required alertType is missing', async () => {
http.get.mockResolvedValueOnce([
{
id: 'test-another',
name: 'Test Another',
actionVariables: [],
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
recoveryActionGroup: RecoveredActionGroup,
producer: 'alerts',
},
]);
expect(loadAlertType({ http, id: 'test' })).rejects.toMatchInlineSnapshot(
`[Error: Alert type "test" is not registered.]`
);
});
});
describe('loadAlert', () => {

View file

@ -6,7 +6,6 @@
*/
import { HttpSetup } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { LEGACY_BASE_ALERT_API_PATH } from '../common';
import type { Alert, AlertType } from '../common';
@ -20,21 +19,11 @@ export async function loadAlertType({
}: {
http: HttpSetup;
id: AlertType['id'];
}): Promise<AlertType> {
const maybeAlertType = ((await http.get(
}): Promise<AlertType | undefined> {
const alertTypes = (await http.get(
`${LEGACY_BASE_ALERT_API_PATH}/list_alert_types`
)) as AlertType[]).find((type) => type.id === id);
if (!maybeAlertType) {
throw new Error(
i18n.translate('xpack.alerting.loadAlertType.missingAlertTypeError', {
defaultMessage: 'Alert type "{id}" is not registered.',
values: {
id,
},
})
);
}
return maybeAlertType;
)) as AlertType[];
return alertTypes.find((type) => type.id === id);
}
export async function loadAlert({

View file

@ -30,14 +30,19 @@ export class AlertingPublicPlugin implements Plugin<PluginSetupContract, PluginS
const registerNavigation = async (
consumer: string,
alertType: string,
alertTypeId: string,
handler: AlertNavigationHandler
) =>
this.alertNavigationRegistry!.register(
consumer,
await loadAlertType({ http: core.http, id: alertType }),
handler
);
) => {
const alertType = await loadAlertType({ http: core.http, id: alertTypeId });
if (!alertType) {
// eslint-disable-next-line no-console
console.log(
`Unable to register navigation for alert type "${alertTypeId}" because it is not registered on the server side.`
);
return;
}
this.alertNavigationRegistry!.register(consumer, alertType, handler);
};
const registerDefaultNavigation = async (consumer: string, handler: AlertNavigationHandler) =>
this.alertNavigationRegistry!.registerDefault(consumer, handler);
@ -54,6 +59,14 @@ export class AlertingPublicPlugin implements Plugin<PluginSetupContract, PluginS
const alert = await loadAlert({ http: core.http, alertId });
const alertType = await loadAlertType({ http: core.http, id: alert.alertTypeId });
if (!alertType) {
// eslint-disable-next-line no-console
console.log(
`Unable to get navigation for alert type "${alert.alertTypeId}" because it is not registered on the server side.`
);
return;
}
if (this.alertNavigationRegistry!.has(alert.consumer, alertType)) {
const navigationHandler = this.alertNavigationRegistry!.get(alert.consumer, alertType);
const state = navigationHandler(alert, alertType);

View file

@ -19,6 +19,11 @@ body.canvas-isFullscreen {
display: none;
}
// hide global banners
#globalBannerList {
display: none;
}
// set the background color
.canvasLayout {
background: $euiColorInk;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { get, sortBy } from 'lodash';
import { get } from 'lodash';
import { PointSeriesColumns, DatatableRow, Ticks } from '../../../types';
export const getTickHash = (columns: PointSeriesColumns, rows: DatatableRow[]) => {
@ -21,23 +21,19 @@ export const getTickHash = (columns: PointSeriesColumns, rows: DatatableRow[]) =
};
if (get(columns, 'x.type') === 'string') {
sortBy(rows, ['x'])
.reverse()
.forEach((row) => {
if (!ticks.x.hash[row.x]) {
ticks.x.hash[row.x] = ticks.x.counter++;
}
});
rows.forEach((row) => {
if (!ticks.x.hash[row.x]) {
ticks.x.hash[row.x] = ticks.x.counter++;
}
});
}
if (get(columns, 'y.type') === 'string') {
sortBy(rows, ['y'])
.reverse()
.forEach((row) => {
if (!ticks.y.hash[row.y]) {
ticks.y.hash[row.y] = ticks.y.counter++;
}
});
rows.reverse().forEach((row) => {
if (!ticks.y.hash[row.y]) {
ticks.y.hash[row.y] = ticks.y.counter++;
}
});
}
return ticks;

View file

@ -81,8 +81,7 @@ export function plotFunctionFactory(
fn: (input, args) => {
const seriesStyles: { [key: string]: SeriesStyle } =
keyBy(args.seriesStyle || [], 'label') || {};
const sortedRows = sortBy(input.rows, ['x', 'y', 'color', 'size', 'text']);
const sortedRows = input.rows;
const ticks = getTickHash(input.columns, sortedRows);
const font = args.font ? getFontSpec(args.font) : {};

View file

@ -1,3 +1,11 @@
# `cloud` plugin
The `cloud` plugin adds cloud specific features to Kibana.
The client-side plugin configures following values:
- `isCloudEnabled = true` for both ESS and ECE deployments
- `cloudId` is the ID of the Cloud deployment Kibana is running on
- `baseUrl` is the URL of the Cloud interface, for Elastic Cloud production environment the value is `https://cloud.elastic.co`
- `deploymentUrl` is the URL of the specific Cloud deployment Kibana is running on, the value is already concatenated with `baseUrl`
- `profileUrl` is the URL of the Cloud user profile page, the value is already concatenated with `baseUrl`
- `organizationUrl` is the URL of the Cloud account (& billing) page, the value is already concatenated with `baseUrl`
- `cname` value is the same as `baseUrl` on ESS but can be customized on ECE

View file

@ -0,0 +1,190 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { nextTick } from '@kbn/test/jest';
import { coreMock } from 'src/core/public/mocks';
import { homePluginMock } from 'src/plugins/home/public/mocks';
import { securityMock } from '../../security/public/mocks';
import { CloudPlugin } from './plugin';
describe('Cloud Plugin', () => {
describe('#start', () => {
function setupPlugin({
roles = [],
simulateUserError = false,
}: { roles?: string[]; simulateUserError?: boolean } = {}) {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext({
id: 'cloudId',
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/profile/alice',
organization_url: '/org/myOrg',
})
);
const coreSetup = coreMock.createSetup();
const homeSetup = homePluginMock.createSetupContract();
const securitySetup = securityMock.createSetup();
if (simulateUserError) {
securitySetup.authc.getCurrentUser.mockRejectedValue(new Error('Something happened'));
} else {
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser({
roles,
})
);
}
plugin.setup(coreSetup, { home: homeSetup, security: securitySetup });
return { coreSetup, securitySetup, plugin };
}
it('registers help support URL', async () => {
const { plugin } = setupPlugin();
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
expect(coreStart.chrome.setHelpSupportUrl).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setHelpSupportUrl.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"https://support.elastic.co/",
]
`);
});
it('registers a custom nav link for superusers', async () => {
const { plugin } = setupPlugin({ roles: ['superuser'] });
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"euiIconType": "arrowLeft",
"href": "https://cloud.elastic.co/abc123",
"title": "Manage this deployment",
},
]
`);
});
it('registers a custom nav link when there is an error retrieving the current user', async () => {
const { plugin } = setupPlugin({ simulateUserError: true });
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(coreStart.chrome.setCustomNavLink).toHaveBeenCalledTimes(1);
expect(coreStart.chrome.setCustomNavLink.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"euiIconType": "arrowLeft",
"href": "https://cloud.elastic.co/abc123",
"title": "Manage this deployment",
},
]
`);
});
it('does not register a custom nav link for non-superusers', async () => {
const { plugin } = setupPlugin({ roles: ['not-a-superuser'] });
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(coreStart.chrome.setCustomNavLink).not.toHaveBeenCalled();
});
it('registers user profile links for superusers', async () => {
const { plugin } = setupPlugin({ roles: ['superuser'] });
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1);
expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "https://cloud.elastic.co/profile/alice",
"iconType": "user",
"label": "Profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "https://cloud.elastic.co/org/myOrg",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
},
],
]
`);
});
it('registers profile links when there is an error retrieving the current user', async () => {
const { plugin } = setupPlugin({ simulateUserError: true });
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(securityStart.navControlService.addUserMenuLinks).toHaveBeenCalledTimes(1);
expect(securityStart.navControlService.addUserMenuLinks.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"href": "https://cloud.elastic.co/profile/alice",
"iconType": "user",
"label": "Profile",
"order": 100,
"setAsProfile": true,
},
Object {
"href": "https://cloud.elastic.co/org/myOrg",
"iconType": "gear",
"label": "Account & Billing",
"order": 200,
},
],
]
`);
});
it('does not register profile links for non-superusers', async () => {
const { plugin } = setupPlugin({ roles: ['not-a-superuser'] });
const coreStart = coreMock.createStart();
const securityStart = securityMock.createStart();
plugin.start(coreStart, { security: securityStart });
await nextTick();
expect(securityStart.navControlService.addUserMenuLinks).not.toHaveBeenCalled();
});
});
});

View file

@ -7,7 +7,7 @@
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
import { i18n } from '@kbn/i18n';
import { SecurityPluginStart } from '../../security/public';
import { AuthenticatedUser, SecurityPluginSetup, SecurityPluginStart } from '../../security/public';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import { ELASTIC_SUPPORT_LINK } from '../common/constants';
import { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
@ -25,6 +25,7 @@ export interface CloudConfigType {
interface CloudSetupDependencies {
home?: HomePublicPluginSetup;
security?: Pick<SecurityPluginSetup, 'authc'>;
}
interface CloudStartDependencies {
@ -44,13 +45,14 @@ export interface CloudSetup {
export class CloudPlugin implements Plugin<CloudSetup> {
private config!: CloudConfigType;
private isCloudEnabled: boolean;
private authenticatedUserPromise?: Promise<AuthenticatedUser | null>;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<CloudConfigType>();
this.isCloudEnabled = false;
}
public setup(core: CoreSetup, { home }: CloudSetupDependencies) {
public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) {
const {
id,
cname,
@ -68,6 +70,10 @@ export class CloudPlugin implements Plugin<CloudSetup> {
}
}
if (security) {
this.authenticatedUserPromise = security.authc.getCurrentUser().catch(() => null);
}
return {
cloudId: id,
cname,
@ -82,19 +88,47 @@ export class CloudPlugin implements Plugin<CloudSetup> {
public start(coreStart: CoreStart, { security }: CloudStartDependencies) {
const { deployment_url: deploymentUrl, base_url: baseUrl } = this.config;
coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK);
if (baseUrl && deploymentUrl) {
coreStart.chrome.setCustomNavLink({
title: i18n.translate('xpack.cloud.deploymentLinkLabel', {
defaultMessage: 'Manage this deployment',
}),
euiIconType: 'arrowLeft',
href: getFullCloudUrl(baseUrl, deploymentUrl),
});
}
if (security && this.isCloudEnabled) {
const userMenuLinks = createUserMenuLinks(this.config);
security.navControlService.addUserMenuLinks(userMenuLinks);
}
const setLinks = (authorized: boolean) => {
if (!authorized) return;
if (baseUrl && deploymentUrl) {
coreStart.chrome.setCustomNavLink({
title: i18n.translate('xpack.cloud.deploymentLinkLabel', {
defaultMessage: 'Manage this deployment',
}),
euiIconType: 'arrowLeft',
href: getFullCloudUrl(baseUrl, deploymentUrl),
});
}
if (security && this.isCloudEnabled) {
const userMenuLinks = createUserMenuLinks(this.config);
security.navControlService.addUserMenuLinks(userMenuLinks);
}
};
this.checkIfAuthorizedForLinks()
.then(setLinks)
// In the event of an unexpected error, fail *open*.
// Cloud admin console will always perform the actual authorization checks.
.catch(() => setLinks(true));
}
/**
* Determines if the current user should see links back to Cloud.
* This isn't a true authorization check, but rather a heuristic to
* see if the current user is *likely* a cloud deployment administrator.
*
* At this point, we do not have enough information to reliably make this determination,
* but we do know that all cloud deployment admins are superusers by default.
*/
private async checkIfAuthorizedForLinks() {
// Security plugin is disabled
if (!this.authenticatedUserPromise) return true;
// Otherwise check roles. If user is not defined due to an unexpected error, then fail *open*.
// Cloud admin console will always perform the actual authorization checks.
const user = await this.authenticatedUserPromise;
return user?.roles.includes('superuser') ?? true;
}
}

View file

@ -10,6 +10,7 @@ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'src/core/se
import { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import { parseDeploymentIdFromDeploymentUrl } from './utils';
interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
@ -17,6 +18,7 @@ interface PluginsSetup {
export interface CloudSetup {
cloudId?: string;
deploymentId?: string;
isCloudEnabled: boolean;
apm: {
url?: string;
@ -40,6 +42,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
return {
cloudId: this.config.id,
deploymentId: parseDeploymentIdFromDeploymentUrl(this.config.deployment_url),
isCloudEnabled,
apm: {
url: this.config.apm?.url,

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { parseDeploymentIdFromDeploymentUrl } from './utils';
describe('parseDeploymentIdFromDeploymentUrl', () => {
it('should return undefined if there is no deploymentUrl configured', () => {
expect(parseDeploymentIdFromDeploymentUrl()).toBeUndefined();
});
it('should return the deploymentId if this is a valid deployment url', () => {
expect(parseDeploymentIdFromDeploymentUrl('deployments/uuid-deployment-1')).toBe(
'uuid-deployment-1'
);
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export function parseDeploymentIdFromDeploymentUrl(deploymentUrl?: string) {
if (!deploymentUrl) {
return;
}
return deploymentUrl.split('/').pop();
}

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { EmptyState } from './';
describe('EmptyState', () => {
it('renders', () => {
const wrapper = shallow(<EmptyState />)
.find(EuiEmptyPrompt)
.dive();
expect(wrapper.find('h2').text()).toEqual('Create your first synonym set');
expect(wrapper.find(EuiButton).prop('href')).toEqual(
expect.stringContaining('/synonyms-guide.html')
);
});
});

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiPanel, EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DOCS_PREFIX } from '../../../routes';
import { SynonymIcon } from './';
export const EmptyState: React.FC = () => {
return (
<EuiPanel color="subdued">
<EuiEmptyPrompt
iconType={SynonymIcon}
title={
<h2>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.title', {
defaultMessage: 'Create your first synonym set',
})}
</h2>
}
body={
<p>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.description', {
defaultMessage:
'Synonyms relate queries with similar context or meaning together. Use them to guide users to relevant content.',
})}
</p>
}
actions={
<EuiButton
size="s"
target="_blank"
iconType="popout"
href={`${DOCS_PREFIX}/synonyms-guide.html`}
>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.empty.buttonLabel', {
defaultMessage: 'Read the synonyms guide',
})}
</EuiButton>
}
/>
</EuiPanel>
);
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { SynonymIcon } from './synonym_icon';
export { SynonymCard } from './synonym_card';
export { EmptyState } from './empty_state';

View file

@ -0,0 +1,38 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EuiCard, EuiButton } from '@elastic/eui';
import { SynonymCard, SynonymIcon } from './';
describe('SynonymCard', () => {
const MOCK_SYNONYM_SET = {
id: 'syn-1234567890',
synonyms: ['lorem', 'ipsum', 'dolor', 'sit', 'amet'],
};
const wrapper = shallow(<SynonymCard {...MOCK_SYNONYM_SET} />)
.find(EuiCard)
.dive();
it('renders with the first synonym as the title', () => {
expect(wrapper.find('h2').text()).toEqual('lorem');
});
it('renders a synonym icon for each subsequent synonym', () => {
expect(wrapper.find(SynonymIcon)).toHaveLength(4);
});
it('renders a manage synonym button', () => {
wrapper.find(EuiButton).simulate('click');
// TODO: expect open modal action
});
});

View file

@ -0,0 +1,45 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiText, EuiButton } from '@elastic/eui';
import { MANAGE_BUTTON_LABEL } from '../../../../shared/constants';
import { SynonymSet } from '../types';
import { SynonymIcon } from './';
export const SynonymCard: React.FC<SynonymSet> = (synonymSet) => {
const [firstSynonym, ...remainingSynonyms] = synonymSet.synonyms;
return (
<EuiCard
display="subdued"
title={firstSynonym}
titleElement="h2"
titleSize="s"
textAlign="left"
footer={
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton onClick={() => {} /* TODO */}>{MANAGE_BUTTON_LABEL}</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiText size="m">
{remainingSynonyms.map((synonym) => (
<div key={synonym}>
<SynonymIcon /> {synonym}
</div>
))}
</EuiText>
</EuiCard>
);
};

View file

@ -0,0 +1,19 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { SynonymIcon } from './';
describe('SynonymIcon', () => {
it('renders', () => {
const wrapper = shallow(<SynonymIcon />);
expect(wrapper.hasClass('euiIcon')).toBe(true);
});
});

View file

@ -0,0 +1,26 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
export const SynonymIcon: React.FC = ({ ...props }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
width="18"
height="18"
className="euiIcon euiIcon--subdued euiIcon--medium"
{...props}
aria-label={i18n.translate('xpack.enterpriseSearch.appSearch.engine.synonyms.iconAriaLabel', {
defaultMessage: 'synonym for',
})}
>
<path d="M5.477 4.69c-1.1-.043-2.176.7-3.365 2.596a.65.65 0 01-1.101-.69c1.413-2.255 2.883-3.27 4.518-3.204 1.214.048 2.125.522 3.977 1.812l.075.052c3.175 2.212 4.387 2.352 6.33-.339a.65.65 0 111.054.761c-2.48 3.436-4.447 3.209-8.128.645l-.074-.052c-1.64-1.142-2.415-1.546-3.286-1.58zm0 6.35c-1.1-.043-2.176.7-3.365 2.596a.65.65 0 01-1.101-.69c1.413-2.255 2.883-3.27 4.518-3.204 1.214.048 2.125.522 3.977 1.812l.075.052c3.175 2.212 4.387 2.352 6.33-.338a.65.65 0 111.054.76c-2.48 3.436-4.447 3.209-8.128.645l-.074-.052c-1.64-1.142-2.415-1.546-3.286-1.58z" />
</svg>
);

View file

@ -7,6 +7,15 @@
import { i18n } from '@kbn/i18n';
import { DEFAULT_META } from '../../../shared/constants';
export const SYNONYMS_PAGE_META = {
page: {
...DEFAULT_META.page,
size: 12, // Use a multiple of 3, since synonym cards are in rows of 3
},
};
export const SYNONYMS_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.synonyms.title',
{ defaultMessage: 'Synonyms' }

View file

@ -7,3 +7,4 @@
export { SYNONYMS_TITLE } from './constants';
export { Synonyms } from './synonyms';
export { SynonymsLogic } from './synonyms_logic';

View file

@ -5,17 +5,123 @@
* 2.0.
*/
import { setMockValues, setMockActions, rerender } from '../../../__mocks__';
import '../../../__mocks__/shallow_useeffect.mock';
import '../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiPageHeader, EuiButton, EuiPagination } from '@elastic/eui';
import { Loading } from '../../../shared/loading';
import { SynonymCard, EmptyState } from './components';
import { Synonyms } from './';
describe('Synonyms', () => {
const MOCK_SYNONYM_SET = {
id: 'syn-1234567890',
synonyms: ['a', 'b', 'c'],
};
const values = {
synonymSets: [MOCK_SYNONYM_SET, MOCK_SYNONYM_SET, MOCK_SYNONYM_SET],
meta: { page: { current: 1 } },
dataLoading: false,
};
const actions = {
loadSynonyms: jest.fn(),
onPaginate: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
shallow(<Synonyms />);
// TODO: Check for Synonym cards, Synonym modal
const wrapper = shallow(<Synonyms />);
expect(wrapper.find(SynonymCard)).toHaveLength(3);
// TODO: Check for synonym modal
});
it('renders a create action button', () => {
const wrapper = shallow(<Synonyms />)
.find(EuiPageHeader)
.dive()
.children()
.dive();
wrapper.find(EuiButton).simulate('click');
// TODO: Expect open modal action
});
it('renders an empty state if no synonyms exist', () => {
setMockValues({ ...values, synonymSets: [] });
const wrapper = shallow(<Synonyms />);
expect(wrapper.find(EmptyState)).toHaveLength(1);
});
describe('loading', () => {
it('renders a loading state on initial page load', () => {
setMockValues({ ...values, synonymSets: [], dataLoading: true });
const wrapper = shallow(<Synonyms />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
it('does not render a full loading state after initial page load', () => {
setMockValues({ ...values, synonymSets: [MOCK_SYNONYM_SET], dataLoading: true });
const wrapper = shallow(<Synonyms />);
expect(wrapper.find(Loading)).toHaveLength(0);
});
});
describe('API & pagination', () => {
it('loads synonyms on page load and on pagination', () => {
const wrapper = shallow(<Synonyms />);
expect(actions.loadSynonyms).toHaveBeenCalledTimes(1);
setMockValues({ ...values, meta: { page: { current: 5 } } });
rerender(wrapper);
expect(actions.loadSynonyms).toHaveBeenCalledTimes(2);
});
it('automatically paginations users back a page if they delete the only remaining synonym on the page', () => {
setMockValues({ ...values, meta: { page: { current: 5 } }, synonymSets: [] });
shallow(<Synonyms />);
expect(actions.onPaginate).toHaveBeenCalledWith(4);
});
it('does not paginate backwards if the user is on the first page (should show the state instead)', () => {
setMockValues({ ...values, meta: { page: { current: 1 } }, synonymSets: [] });
const wrapper = shallow(<Synonyms />);
expect(actions.onPaginate).not.toHaveBeenCalled();
expect(wrapper.find(EmptyState)).toHaveLength(1);
});
it('handles off-by-one shenanigans between EuiPagination and our API', () => {
setMockValues({
...values,
meta: { page: { total_pages: 10, current: 1 } },
});
const wrapper = shallow(<Synonyms />);
const pagination = wrapper.find(EuiPagination);
expect(pagination.prop('pageCount')).toEqual(10);
expect(pagination.prop('activePage')).toEqual(0);
pagination.simulate('pageClick', 4);
expect(actions.onPaginate).toHaveBeenCalledWith(5);
});
});
});

View file

@ -5,23 +5,86 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui';
import { useValues, useActions } from 'kea';
import {
EuiPageHeader,
EuiButton,
EuiPageContentBody,
EuiSpacer,
EuiFlexGrid,
EuiFlexItem,
EuiPagination,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FlashMessages } from '../../../shared/flash_messages';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { Loading } from '../../../shared/loading';
import { getEngineBreadcrumbs } from '../engine';
import { SynonymCard, EmptyState } from './components';
import { SYNONYMS_TITLE } from './constants';
import { SynonymsLogic } from './';
export const Synonyms: React.FC = () => {
const { loadSynonyms, onPaginate } = useActions(SynonymsLogic);
const { synonymSets, meta, dataLoading } = useValues(SynonymsLogic);
const hasSynonyms = synonymSets.length > 0;
useEffect(() => {
loadSynonyms();
}, [meta.page.current]);
useEffect(() => {
// If users delete the only synonym set on the page, send them back to the previous page
if (!hasSynonyms && meta.page.current !== 1) {
onPaginate(meta.page.current - 1);
}
}, [synonymSets]);
if (dataLoading && !hasSynonyms) return <Loading />;
return (
<>
<SetPageChrome trail={getEngineBreadcrumbs([SYNONYMS_TITLE])} />
<EuiPageHeader pageTitle={SYNONYMS_TITLE} />
<EuiPageHeader
pageTitle={SYNONYMS_TITLE}
rightSideItems={[
<EuiButton fill onClick={() => {} /* TODO */}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.synonyms.createSynonymSetButtonLabel',
{ defaultMessage: 'Create a synonym set' }
)}
</EuiButton>,
]}
/>
<FlashMessages />
<EuiPageContentBody>TODO</EuiPageContentBody>
<EuiPageContentBody>
<EuiSpacer size="s" />
{hasSynonyms ? (
<>
<EuiFlexGrid columns={3}>
{synonymSets.map(({ id, synonyms }) => (
<EuiFlexItem key={id}>
<SynonymCard id={id} synonyms={synonyms} />
</EuiFlexItem>
))}
</EuiFlexGrid>
<EuiSpacer />
<EuiPagination
pageCount={meta.page.total_pages}
activePage={meta.page.current - 1}
onPageClick={(pageIndex) => onPaginate(pageIndex + 1)}
/>
</>
) : (
<EmptyState />
)}
</EuiPageContentBody>
</>
);
};

View file

@ -0,0 +1,125 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__';
import '../../__mocks__/engine_logic.mock';
import { nextTick } from '@kbn/test/jest';
import { SYNONYMS_PAGE_META } from './constants';
import { SynonymsLogic } from './';
describe('SynonymsLogic', () => {
const { mount } = new LogicMounter(SynonymsLogic);
const { http } = mockHttpValues;
const { flashAPIErrors } = mockFlashMessageHelpers;
const MOCK_SYNONYMS_RESPONSE = {
meta: {
page: {
current: 1,
size: 12,
total_results: 1,
total_pages: 1,
},
},
results: [
{
id: 'some-synonym-id',
synonyms: ['hello', 'world'],
},
],
};
const DEFAULT_VALUES = {
dataLoading: true,
synonymSets: [],
meta: SYNONYMS_PAGE_META,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('has expected default values', () => {
mount();
expect(SynonymsLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('onSynonymsLoad', () => {
it('should set synonyms and meta state, & dataLoading to false', () => {
mount();
SynonymsLogic.actions.onSynonymsLoad(MOCK_SYNONYMS_RESPONSE);
expect(SynonymsLogic.values).toEqual({
...DEFAULT_VALUES,
synonymSets: MOCK_SYNONYMS_RESPONSE.results,
meta: MOCK_SYNONYMS_RESPONSE.meta,
dataLoading: false,
});
});
});
describe('onPaginate', () => {
it('should set meta.page.current state', () => {
mount();
SynonymsLogic.actions.onPaginate(3);
expect(SynonymsLogic.values).toEqual({
...DEFAULT_VALUES,
meta: { page: { ...DEFAULT_VALUES.meta.page, current: 3 } },
});
});
});
});
describe('listeners', () => {
describe('loadSynonyms', () => {
it('should set dataLoading state', () => {
mount({ dataLoading: false });
SynonymsLogic.actions.loadSynonyms();
expect(SynonymsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: true,
});
});
it('should make an API call and set synonyms & meta state', async () => {
http.get.mockReturnValueOnce(Promise.resolve(MOCK_SYNONYMS_RESPONSE));
mount();
jest.spyOn(SynonymsLogic.actions, 'onSynonymsLoad');
SynonymsLogic.actions.loadSynonyms();
await nextTick();
expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/synonyms', {
query: {
'page[current]': 1,
'page[size]': 12,
},
});
expect(SynonymsLogic.actions.onSynonymsLoad).toHaveBeenCalledWith(MOCK_SYNONYMS_RESPONSE);
});
it('handles errors', async () => {
http.get.mockReturnValueOnce(Promise.reject('error'));
mount();
SynonymsLogic.actions.loadSynonyms();
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('error');
});
});
});
});

View file

@ -0,0 +1,79 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { Meta } from '../../../../../common/types';
import { flashAPIErrors } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { updateMetaPageIndex } from '../../../shared/table_pagination';
import { EngineLogic } from '../engine';
import { SYNONYMS_PAGE_META } from './constants';
import { SynonymSet, SynonymsApiResponse } from './types';
interface SynonymsValues {
dataLoading: boolean;
synonymSets: SynonymSet[];
meta: Meta;
}
interface SynonymsActions {
loadSynonyms(): void;
onSynonymsLoad(response: SynonymsApiResponse): SynonymsApiResponse;
onPaginate(newPageIndex: number): { newPageIndex: number };
}
export const SynonymsLogic = kea<MakeLogicType<SynonymsValues, SynonymsActions>>({
path: ['enterprise_search', 'app_search', 'synonyms_logic'],
actions: () => ({
loadSynonyms: true,
onSynonymsLoad: ({ results, meta }) => ({ results, meta }),
onPaginate: (newPageIndex) => ({ newPageIndex }),
}),
reducers: () => ({
dataLoading: [
true,
{
loadSynonyms: () => true,
onSynonymsLoad: () => false,
},
],
synonymSets: [
[],
{
onSynonymsLoad: (_, { results }) => results,
},
],
meta: [
SYNONYMS_PAGE_META,
{
onSynonymsLoad: (_, { meta }) => meta,
onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex),
},
],
}),
listeners: ({ actions, values }) => ({
loadSynonyms: async () => {
const { meta } = values;
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
try {
const response = await http.get(`/api/app_search/engines/${engineName}/synonyms`, {
query: {
'page[current]': meta.page.current,
'page[size]': meta.page.size,
},
});
actions.onSynonymsLoad(response);
} catch (e) {
flashAPIErrors(e);
}
},
}),
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Meta } from '../../../../../common/types';
export interface SynonymSet {
id: string;
synonyms: string[];
}
export interface SynonymsApiResponse {
results: SynonymSet[];
meta: Meta;
}

View file

@ -14,18 +14,7 @@ import { usePackageIconType } from '../hooks';
export const PackageIcon: React.FunctionComponent<
UsePackageIconType & Omit<EuiIconProps, 'type'>
> = ({ size = 's', packageName, version, icons, tryApi, ...euiIconProps }) => {
> = ({ packageName, version, icons, tryApi, ...euiIconProps }) => {
const iconType = usePackageIconType({ packageName, version, icons, tryApi });
return (
<EuiIcon
// when a custom SVG is used the logo is rendered with <img class="euiIcon euiIcon--small">
// this collides with some EuiText (+img) CSS from the EuiIcon component
// which makes the button large, wide, and poorly layed out
// override those styles until the bug is fixed or we find a better approach
style={{ margin: 'unset', width: 'unset' }}
size={size}
type={iconType}
{...euiIconProps}
/>
);
return <EuiIcon size="s" type={iconType} {...euiIconProps} />;
};

View file

@ -71,7 +71,18 @@ export const AgentPolicyPackageBadges: React.FunctionComponent<Props> = ({
<EuiBadge key={idx} color="hollow">
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<PackageIcon packageName={pkg.name} version={pkg.version} size="s" tryApi={true} />
<PackageIcon
packageName={pkg.name}
version={pkg.version}
tryApi={true}
style={
// when a custom SVG is used the logo is rendered with <img class="euiIcon euiIcon--small">
// this collides with some EuiText (+img) CSS from the EuiIcon component
// which makes the button large, wide, and poorly layed out
// override those styles until the bug is fixed or we find a better approach
{ margin: 'unset', width: '16px' }
}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{pkg.title}</EuiFlexItem>
</EuiFlexGroup>

View file

@ -1,62 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import styled from 'styled-components';
import { EuiIcon, EuiPanel } from '@elastic/eui';
import type { UsePackageIconType } from '../../../hooks';
import { usePackageIconType } from '../../../hooks';
import { Loading } from '../../../components';
const PanelWrapper = styled.div`
// NOTE: changes to the width here will impact navigation tabs page layout under integration package details
width: ${(props) =>
parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.euiSizeXL) * 2}px;
height: 1px;
z-index: 1;
`;
const Panel = styled(EuiPanel)`
padding: ${(props) => props.theme.eui.spacerSizes.xl};
margin-bottom: -100%;
svg,
img {
height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px;
width: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px;
}
.euiFlexItem {
height: ${(props) => parseFloat(props.theme.eui.euiSize) * 6}px;
justify-content: center;
}
`;
export function IconPanel({
packageName,
version,
icons,
}: Pick<UsePackageIconType, 'packageName' | 'version' | 'icons'>) {
const iconType = usePackageIconType({ packageName, version, icons });
return (
<PanelWrapper>
<Panel>
<EuiIcon type={iconType} size="original" />
</Panel>
</PanelWrapper>
);
}
export function LoadingIconPanel() {
return (
<PanelWrapper>
<Panel>
<Loading />
</Panel>
</PanelWrapper>
);
}

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { appContextService } from './app_context';
import { getCloudFleetServersHosts } from './settings';
jest.mock('./app_context');
const mockedAppContextService = appContextService as jest.Mocked<typeof appContextService>;
describe('getCloudFleetServersHosts', () => {
it('should return undefined if cloud is not setup', () => {
expect(getCloudFleetServersHosts()).toBeUndefined();
});
it('should return fleet server hosts if cloud is correctly setup', () => {
mockedAppContextService.getCloud.mockReturnValue({
cloudId:
'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==',
isCloudEnabled: true,
deploymentId: 'deployment-id-1',
apm: {},
});
expect(getCloudFleetServersHosts()).toMatchInlineSnapshot(`
Array [
"https://deployment-id-1.fleet.us-east-1.aws.found.io",
]
`);
});
});

View file

@ -8,7 +8,7 @@
import Boom from '@hapi/boom';
import type { SavedObjectsClientContract } from 'kibana/server';
import { GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../common';
import { decodeCloudId, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '../../common';
import type { SettingsSOAttributes, Settings, BaseSettings } from '../../common';
import { appContextService } from './app_context';
@ -65,9 +65,25 @@ export async function saveSettings(
}
export function createDefaultSettings(): BaseSettings {
const fleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts ?? [];
const configFleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts;
const cloudFleetServerHosts = getCloudFleetServersHosts();
const fleetServerHosts = configFleetServerHosts ?? cloudFleetServerHosts ?? [];
return {
fleet_server_hosts: fleetServerHosts,
};
}
export function getCloudFleetServersHosts() {
const cloudSetup = appContextService.getCloud();
if (cloudSetup && cloudSetup.isCloudEnabled && cloudSetup.cloudId && cloudSetup.deploymentId) {
const res = decodeCloudId(cloudSetup.cloudId);
if (!res) {
return;
}
// Fleet Server url are formed like this `https://<deploymentId>.fleet.<host>
return [`https://${cloudSetup.deploymentId}.fleet.${res.host}`];
}
}

View file

@ -11,7 +11,6 @@ import { IHttpFetchError } from 'src/core/public';
import { InvalidNodeError } from './invalid_node';
import { DocumentTitle } from '../../../../components/document_title';
import { ErrorPageBody } from '../../../error';
interface Props {
name: string;
error: IHttpFetchError;
@ -30,13 +29,11 @@ export const PageError = ({ error, name }: Props) => {
})
}
/>
{
(error.body.statusCode = 404 ? (
<InvalidNodeError nodeName={name} />
) : (
<ErrorPageBody message={error.message} />
))
}
{error.body?.statusCode === 404 ? (
<InvalidNodeError nodeName={name} />
) : (
<ErrorPageBody message={error.message} />
)}
</>
);
};

View file

@ -5,19 +5,15 @@
}
.lnsApp {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.lnsApp__header {
border-bottom: $euiBorderThin;
> .kbnTopNavMenu__wrapper {
border-bottom: $euiBorderThin;
}
}
.lnsApp__frame {

View file

@ -648,68 +648,66 @@ export function App({
return (
<>
<div className="lnsApp">
<div className="lnsApp__header">
<TopNavMenu
setMenuMountPoint={setHeaderActionMenu}
config={topNavConfig}
showSearchBar={true}
showDatePicker={true}
showQueryBar={true}
showFilterBar={true}
indexPatterns={state.indexPatternsForTopNav}
showSaveQuery={Boolean(application.capabilities.visualize.saveQuery)}
savedQuery={state.savedQuery}
data-test-subj="lnsApp_topNav"
screenTitle={'lens'}
appName={'lens'}
onQuerySubmit={(payload) => {
const { dateRange, query } = payload;
const currentRange = data.query.timefilter.timefilter.getTime();
if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) {
data.query.timefilter.timefilter.setTime(dateRange);
trackUiEvent('app_date_change');
} else {
// Query has changed, renew the session id.
// Time change will be picked up by the time subscription
setState((s) => ({
...s,
searchSessionId: startSession(),
}));
trackUiEvent('app_query_change');
}
<TopNavMenu
setMenuMountPoint={setHeaderActionMenu}
config={topNavConfig}
showSearchBar={true}
showDatePicker={true}
showQueryBar={true}
showFilterBar={true}
indexPatterns={state.indexPatternsForTopNav}
showSaveQuery={Boolean(application.capabilities.visualize.saveQuery)}
savedQuery={state.savedQuery}
data-test-subj="lnsApp_topNav"
screenTitle={'lens'}
appName={'lens'}
onQuerySubmit={(payload) => {
const { dateRange, query } = payload;
const currentRange = data.query.timefilter.timefilter.getTime();
if (dateRange.from !== currentRange.from || dateRange.to !== currentRange.to) {
data.query.timefilter.timefilter.setTime(dateRange);
trackUiEvent('app_date_change');
} else {
// Query has changed, renew the session id.
// Time change will be picked up by the time subscription
setState((s) => ({
...s,
query: query || s.query,
searchSessionId: startSession(),
}));
}}
onSaved={(savedQuery) => {
setState((s) => ({ ...s, savedQuery }));
}}
onSavedQueryUpdated={(savedQuery) => {
const savedQueryFilters = savedQuery.attributes.filters || [];
const globalFilters = data.query.filterManager.getGlobalFilters();
data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]);
setState((s) => ({
...s,
savedQuery: { ...savedQuery }, // Shallow query for reference issues
query: savedQuery.attributes.query,
}));
}}
onClearSavedQuery={() => {
data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters());
setState((s) => ({
...s,
savedQuery: undefined,
filters: data.query.filterManager.getGlobalFilters(),
query: data.query.queryString.getDefaultQuery(),
}));
}}
query={state.query}
dateRangeFrom={fromDate}
dateRangeTo={toDate}
indicateNoData={state.indicateNoData}
/>
</div>
trackUiEvent('app_query_change');
}
setState((s) => ({
...s,
query: query || s.query,
}));
}}
onSaved={(savedQuery) => {
setState((s) => ({ ...s, savedQuery }));
}}
onSavedQueryUpdated={(savedQuery) => {
const savedQueryFilters = savedQuery.attributes.filters || [];
const globalFilters = data.query.filterManager.getGlobalFilters();
data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]);
setState((s) => ({
...s,
savedQuery: { ...savedQuery }, // Shallow query for reference issues
query: savedQuery.attributes.query,
}));
}}
onClearSavedQuery={() => {
data.query.filterManager.setFilters(data.query.filterManager.getGlobalFilters());
setState((s) => ({
...s,
savedQuery: undefined,
filters: data.query.filterManager.getGlobalFilters(),
query: data.query.queryString.getDefaultQuery(),
}));
}}
query={state.query}
dateRangeFrom={fromDate}
dateRangeTo={toDate}
indicateNoData={state.indicateNoData}
/>
{(!state.isLoading || state.persistedDoc) && (
<MemoizedEditorFrameWrapper
editorFrame={editorFrame}

View file

@ -74,7 +74,7 @@ export function ColorIndicator({
}
return (
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexGroup gutterSize="none" alignItems="center" responsive={false}>
{indicatorIcon}
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,11 +6,15 @@
@include euiFlyout;
// But with custom positioning to keep it within the sidebar contents
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance;
@include euiBreakpoint('l', 'xl') {
top: 0 !important;
height: 100% !important;
}
@include euiBreakpoint('xs', 's', 'm') {
@include euiFlyout;
}
}
.lnsDimensionContainer__footer {
@ -49,3 +53,7 @@
background-color: transparent;
}
}
.lnsBody--overflowHidden {
overflow: hidden;
}

View file

@ -61,6 +61,17 @@ export function DimensionContainer({
[closeFlyout]
);
useEffect(() => {
if (isOpen) {
document.body.classList.add('lnsBody--overflowHidden');
} else {
document.body.classList.remove('lnsBody--overflowHidden');
}
return () => {
document.body.classList.remove('lnsBody--overflowHidden');
};
});
return isOpen ? (
<EuiFocusTrap disabled={!focusTrapIsEnabled} clickOutsideDisables={true}>
<EuiWindowEvent event="keydown" handler={closeOnEscape} />
@ -68,7 +79,7 @@ export function DimensionContainer({
<div
role="dialog"
aria-labelledby="lnsDimensionContainerTitle"
className="lnsDimensionContainer"
className="lnsDimensionContainer euiFlyout"
>
<EuiFlyoutHeader hasBorder className="lnsDimensionContainer__header">
<EuiFlexGroup
@ -76,6 +87,7 @@ export function DimensionContainer({
alignItems="center"
className="lnsDimensionContainer__headerLink"
onClick={closeFlyout}
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiButtonIcon

View file

@ -9,12 +9,49 @@
bottom: 0;
overflow: hidden;
flex-direction: column;
@include euiBreakpoint('xs', 's', 'm') {
position: static;
}
}
.lnsFrameLayout__pageContent {
overflow: hidden;
flex-grow: 1;
flex-direction: row;
@include euiBreakpoint('xs', 's', 'm') {
flex-wrap: wrap;
overflow: auto;
> * {
flex-basis: 100%;
}
> .lnsFrameLayout__sidebar {
min-height: $euiSizeL * 15;
}
}
}
.visEditor {
@include flexParent();
height: 100%;
@include euiBreakpoint('xs', 's', 'm') {
.visualization {
// While we are on a small screen the visualization is below the
// editor. In this cases it needs a minimum height, since it would otherwise
// maybe end up with 0 height since it just gets the flexbox rest of the screen.
min-height: $euiSizeL * 15;
}
}
/* 1. Without setting this to 0 you will run into a bug where the filter bar modal is hidden under
a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */
> .visualize {
height: 100%;
flex: 1 1 auto;
display: flex;
z-index: 0; /* 1 */
}
}
.lnsFrameLayout__pageBody {
@ -51,6 +88,10 @@
max-width: $euiFormMaxWidth + $euiSizeXXL;
max-height: 100%;
@include euiBreakpoint('xs', 's', 'm') {
max-width: 100%;
}
.lnsConfigPanel {
@include euiScrollBar;
padding: $euiSize $euiSizeXS $euiSize $euiSize;
@ -58,5 +99,10 @@
overflow-y: scroll;
padding-left: $euiFormMaxWidth + $euiSize;
margin-left: -$euiFormMaxWidth;
@include euiBreakpoint('xs', 's', 'm') {
padding-left: $euiSize;
margin-left: 0;
}
}
}

View file

@ -563,7 +563,12 @@ export const InnerVisualizationWrapper = ({
onData$={onData$}
renderMode="edit"
renderError={(errorMessage?: string | null, error?: ExpressionRenderError | null) => {
const visibleErrorMessages = getOriginalRequestErrorMessages(error) || [errorMessage];
const errorsFromRequest = getOriginalRequestErrorMessages(error);
const visibleErrorMessages = errorsFromRequest.length
? errorsFromRequest
: errorMessage
? [errorMessage]
: [];
return (
<EuiFlexGroup>

View file

@ -59,7 +59,7 @@ export function ChangeIndexPattern({
return (
<>
<EuiPopover
style={{ width: '100%' }}
panelClassName="lnsChangeIndexPatternPopover"
button={createTrigger()}
isOpen={isPopoverOpen}
closePopover={() => setPopoverIsOpen(false)}
@ -67,7 +67,7 @@ export function ChangeIndexPattern({
panelPaddingSize="s"
ownFocus
>
<div style={{ width: 320 }} data-test-subj="lnsChangeIndexPatternPopup">
<div>
<EuiPopoverTitle>
{i18n.translate('xpack.lens.indexPattern.changeIndexPatternTitle', {
defaultMessage: 'Change index pattern',

View file

@ -42,3 +42,7 @@
margin-right: $euiSizeS;
}
}
.lnsChangeIndexPatternPopover {
width: 320px;
}

View file

@ -604,6 +604,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
gutterSize="s"
alignItems="center"
className="lnsInnerIndexPatternDataPanel__header"
responsive={false}
>
<EuiFlexItem grow={true} className="lnsInnerIndexPatternDataPanel__switcher">
<ChangeIndexPattern

View file

@ -662,7 +662,12 @@ function FieldItemPopoverContents(props: State & FieldItemProps) {
const formatted = formatter.convert(topValue.key);
return (
<div className="lnsFieldItem__topValue" key={topValue.key}>
<EuiFlexGroup alignItems="stretch" key={topValue.key} gutterSize="xs">
<EuiFlexGroup
alignItems="stretch"
key={topValue.key}
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={true} className="eui-textTruncate">
{formatted === '' ? (
<EuiText size="xs" color="subdued">
@ -702,7 +707,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) {
})}
{otherCount ? (
<>
<EuiFlexGroup alignItems="stretch" gutterSize="xs">
<EuiFlexGroup alignItems="stretch" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={true} className="eui-textTruncate">
<EuiText size="xs" className="eui-textTruncate" color="subdued">
{i18n.translate('xpack.lens.indexPattern.otherDocsLabel', {

View file

@ -125,7 +125,7 @@ export function PieToolbar(props: VisualizationToolbarProps<PieVisualizationStat
return null;
}
return (
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween">
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" responsive={false}>
<ToolbarPopover
title={i18n.translate('xpack.lens.pieChart.valuesLabel', {
defaultMessage: 'Labels',

View file

@ -195,7 +195,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp
: 'show';
return (
<EuiFlexGroup gutterSize="m" justifyContent="spaceBetween">
<EuiFlexGroup gutterSize="m" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem>
<EuiFlexGroup gutterSize="none" responsive={false}>
<VisualOptionsPopover

View file

@ -5,33 +5,22 @@
* 2.0.
*/
import { LicensingPluginSetup } from '../../../licensing/server';
import { CallAsCurrentUser } from '../types';
const getLicensePath = (acknowledge: boolean) =>
`/_license${acknowledge ? '?acknowledge=true' : ''}`;
import { IScopedClusterClient } from 'kibana/server';
import { LicensingPluginStart } from '../../../licensing/server';
interface PutLicenseArg {
acknowledge: boolean;
callAsCurrentUser: CallAsCurrentUser;
licensing: LicensingPluginSetup;
client: IScopedClusterClient;
licensing: LicensingPluginStart;
license: { [key: string]: any };
}
export async function putLicense({
acknowledge,
callAsCurrentUser,
licensing,
license,
}: PutLicenseArg) {
const options = {
method: 'POST',
path: getLicensePath(acknowledge),
body: license,
};
export async function putLicense({ acknowledge, client, licensing, license }: PutLicenseArg) {
try {
const response = await callAsCurrentUser('transport.request', options);
const { body: response } = await client.asCurrentUser.license.post({
body: license,
acknowledge,
});
const { acknowledged, license_status: licenseStatus } = response;
if (acknowledged && licenseStatus === 'valid') {

View file

@ -5,14 +5,14 @@
* 2.0.
*/
import { CallAsCurrentUser } from '../types';
import { IScopedClusterClient } from 'src/core/server';
interface GetPermissionsArg {
isSecurityEnabled: boolean;
callAsCurrentUser: CallAsCurrentUser;
client: IScopedClusterClient;
}
export async function getPermissions({ isSecurityEnabled, callAsCurrentUser }: GetPermissionsArg) {
export async function getPermissions({ isSecurityEnabled, client }: GetPermissionsArg) {
if (!isSecurityEnabled) {
// If security isn't enabled, let the user use license management
return {
@ -21,15 +21,13 @@ export async function getPermissions({ isSecurityEnabled, callAsCurrentUser }: G
}
const options = {
method: 'POST',
path: '/_security/user/_has_privileges',
body: {
cluster: ['manage'], // License management requires "manage" cluster privileges
},
};
try {
const response = await callAsCurrentUser('transport.request', options);
const { body: response } = await client.asCurrentUser.security.hasPrivileges(options);
return {
hasPermission: response.cluster.manage,
};

View file

@ -5,25 +5,18 @@
* 2.0.
*/
import { LicensingPluginSetup } from '../../../licensing/server';
import { CallAsCurrentUser } from '../types';
const getStartBasicPath = (acknowledge: boolean) =>
`/_license/start_basic${acknowledge ? '?acknowledge=true' : ''}`;
import { IScopedClusterClient } from 'src/core/server';
import { LicensingPluginStart } from '../../../licensing/server';
interface StartBasicArg {
acknowledge: boolean;
callAsCurrentUser: CallAsCurrentUser;
licensing: LicensingPluginSetup;
client: IScopedClusterClient;
licensing: LicensingPluginStart;
}
export async function startBasic({ acknowledge, callAsCurrentUser, licensing }: StartBasicArg) {
const options = {
method: 'POST',
path: getStartBasicPath(acknowledge),
};
export async function startBasic({ acknowledge, client, licensing }: StartBasicArg) {
try {
const response = await callAsCurrentUser('transport.request', options);
const { body: response } = await client.asCurrentUser.license.postStartBasic({ acknowledge });
const { basic_was_started: basicWasStarted } = response;
if (basicWasStarted) {
await licensing.refresh();

View file

@ -5,16 +5,12 @@
* 2.0.
*/
import { LicensingPluginSetup } from '../../../licensing/server';
import { CallAsCurrentUser } from '../types';
import { IScopedClusterClient } from 'src/core/server';
import { LicensingPluginStart } from '../../../licensing/server';
export async function canStartTrial(callAsCurrentUser: CallAsCurrentUser) {
const options = {
method: 'GET',
path: '/_license/trial_status',
};
export async function canStartTrial(client: IScopedClusterClient) {
try {
const response = await callAsCurrentUser('transport.request', options);
const { body: response } = await client.asCurrentUser.license.getTrialStatus();
return response.eligible_to_start_trial;
} catch (error) {
return error.body;
@ -22,17 +18,15 @@ export async function canStartTrial(callAsCurrentUser: CallAsCurrentUser) {
}
interface StartTrialArg {
callAsCurrentUser: CallAsCurrentUser;
licensing: LicensingPluginSetup;
client: IScopedClusterClient;
licensing: LicensingPluginStart;
}
export async function startTrial({ callAsCurrentUser, licensing }: StartTrialArg) {
const options = {
method: 'POST',
path: '/_license/start_trial?acknowledge=true',
};
export async function startTrial({ client, licensing }: StartTrialArg) {
try {
const response = await callAsCurrentUser('transport.request', options);
const { body: response } = await client.asCurrentUser.license.postStartTrial({
acknowledge: true,
});
const { trial_was_started: trialWasStarted } = response;
if (trialWasStarted) {

View file

@ -8,13 +8,17 @@
import { Plugin, CoreSetup } from 'kibana/server';
import { ApiRoutes } from './routes';
import { isEsError } from './shared_imports';
import { Dependencies } from './types';
import { handleEsError } from './shared_imports';
import { SetupDependencies, StartDependencies } from './types';
export class LicenseManagementServerPlugin implements Plugin<void, void, any, any> {
export class LicenseManagementServerPlugin
implements Plugin<void, void, SetupDependencies, StartDependencies> {
private readonly apiRoutes = new ApiRoutes();
setup({ http }: CoreSetup, { licensing, features, security }: Dependencies) {
setup(
{ http, getStartServices }: CoreSetup<StartDependencies>,
{ features, security }: SetupDependencies
) {
const router = http.createRouter();
features.registerElasticsearchFeature({
@ -30,17 +34,19 @@ export class LicenseManagementServerPlugin implements Plugin<void, void, any, an
],
});
this.apiRoutes.setup({
router,
plugins: {
licensing,
},
lib: {
isEsError,
},
config: {
isSecurityEnabled: security !== undefined,
},
getStartServices().then(([, { licensing }]) => {
this.apiRoutes.setup({
router,
plugins: {
licensing,
},
lib: {
handleEsError,
},
config: {
isSecurityEnabled: security !== undefined,
},
});
});
}

View file

@ -10,7 +10,11 @@ import { putLicense } from '../../../lib/license';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../../helpers';
export function registerLicenseRoute({ router, plugins: { licensing } }: RouteDependencies) {
export function registerLicenseRoute({
router,
lib: { handleEsError },
plugins: { licensing },
}: RouteDependencies) {
router.put(
{
path: addBasePath(''),
@ -22,15 +26,19 @@ export function registerLicenseRoute({ router, plugins: { licensing } }: RouteDe
},
},
async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client;
return res.ok({
body: await putLicense({
acknowledge: Boolean(req.query.acknowledge),
callAsCurrentUser,
licensing,
license: req.body,
}),
});
const { client } = ctx.core.elasticsearch;
try {
return res.ok({
body: await putLicense({
acknowledge: Boolean(req.query.acknowledge),
client,
licensing,
license: req.body,
}),
});
} catch (error) {
return handleEsError({ error, response: res });
}
}
);
}

View file

@ -11,13 +11,18 @@ import { addBasePath } from '../../helpers';
export function registerPermissionsRoute({
router,
lib: { handleEsError },
config: { isSecurityEnabled },
}: RouteDependencies) {
router.post({ path: addBasePath('/permissions'), validate: false }, async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client;
const { client } = ctx.core.elasticsearch;
return res.ok({
body: await getPermissions({ callAsCurrentUser, isSecurityEnabled }),
});
try {
return res.ok({
body: await getPermissions({ client, isSecurityEnabled }),
});
} catch (error) {
return handleEsError({ error, response: res });
}
});
}

View file

@ -10,21 +10,29 @@ import { startBasic } from '../../../lib/start_basic';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../../helpers';
export function registerStartBasicRoute({ router, plugins: { licensing } }: RouteDependencies) {
export function registerStartBasicRoute({
router,
lib: { handleEsError },
plugins: { licensing },
}: RouteDependencies) {
router.post(
{
path: addBasePath('/start_basic'),
validate: { query: schema.object({ acknowledge: schema.string() }) },
},
async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client;
return res.ok({
body: await startBasic({
acknowledge: Boolean(req.query.acknowledge),
callAsCurrentUser,
licensing,
}),
});
const { client } = ctx.core.elasticsearch;
try {
return res.ok({
body: await startBasic({
acknowledge: Boolean(req.query.acknowledge),
client,
licensing,
}),
});
} catch (error) {
return handleEsError({ error, response: res });
}
}
);
}

View file

@ -9,16 +9,28 @@ import { canStartTrial, startTrial } from '../../../lib/start_trial';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../../helpers';
export function registerStartTrialRoutes({ router, plugins: { licensing } }: RouteDependencies) {
export function registerStartTrialRoutes({
router,
lib: { handleEsError },
plugins: { licensing },
}: RouteDependencies) {
router.get({ path: addBasePath('/start_trial'), validate: false }, async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client;
return res.ok({ body: await canStartTrial(callAsCurrentUser) });
const { client } = ctx.core.elasticsearch;
try {
return res.ok({ body: await canStartTrial(client) });
} catch (error) {
return handleEsError({ error, response: res });
}
});
router.post({ path: addBasePath('/start_trial'), validate: false }, async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client;
return res.ok({
body: await startTrial({ callAsCurrentUser, licensing }),
});
const { client } = ctx.core.elasticsearch;
try {
return res.ok({
body: await startTrial({ client, licensing }),
});
} catch (error) {
return handleEsError({ error, response: res });
}
});
}

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { isEsError } from '../../../../src/plugins/es_ui_shared/server';
export { handleEsError } from '../../../../src/plugins/es_ui_shared/server';

View file

@ -5,32 +5,35 @@
* 2.0.
*/
import { LegacyScopedClusterClient, IRouter } from 'kibana/server';
import { IScopedClusterClient, IRouter } from 'kibana/server';
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
import { LicensingPluginSetup } from '../../licensing/server';
import { LicensingPluginStart } from '../../licensing/server';
import { SecurityPluginSetup } from '../../security/server';
import { isEsError } from './shared_imports';
import { handleEsError } from './shared_imports';
export interface Dependencies {
licensing: LicensingPluginSetup;
export interface SetupDependencies {
features: FeaturesPluginSetup;
security?: SecurityPluginSetup;
}
export interface StartDependencies {
licensing: LicensingPluginStart;
}
export interface RouteDependencies {
router: IRouter;
plugins: {
licensing: LicensingPluginSetup;
licensing: LicensingPluginStart;
};
lib: {
isEsError: typeof isEsError;
handleEsError: typeof handleEsError;
};
config: {
isSecurityEnabled: boolean;
};
}
export type CallAsCurrentUser = LegacyScopedClusterClient['callAsCurrentUser'];
export type CallAsCurrentUser = IScopedClusterClient['asCurrentUser'];
export type CallAsInternalUser = LegacyScopedClusterClient['callAsInternalUser'];
export type CallAsInternalUser = IScopedClusterClient['asInternalUser'];

View file

@ -54,6 +54,8 @@ export const KBN_TOO_MANY_FEATURES_IMAGE_ID = '__kbn_too_many_features_image_id_
// Centroids are a single point for representing lines, multiLines, polygons, and multiPolygons
export const KBN_IS_CENTROID_FEATURE = '__kbn_is_centroid_feature__';
export const MVT_TOKEN_PARAM_NAME = 'token';
const MAP_BASE_URL = `/${MAPS_APP_PATH}/${MAP_PATH}`;
export function getNewMapPath() {
return MAP_BASE_URL;

View file

@ -7,7 +7,8 @@
import { AbstractField, IField } from './field';
import { FIELD_ORIGIN, MVT_FIELD_TYPE } from '../../../common/constants';
import { ITiledSingleLayerVectorSource, IVectorSource } from '../sources/vector_source';
import { IVectorSource } from '../sources/vector_source';
import { ITiledSingleLayerVectorSource } from '../sources/tiled_single_layer_vector_source';
import { MVTFieldDescriptor } from '../../../common/descriptor_types';
export class MVTField extends AbstractField implements IField {

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