diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/default_value.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/default_value.mdx new file mode 100644 index 0000000000000..5e03d6ad16528 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/default_value.mdx @@ -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 `` component +2. In the **form** `defaultValue` config passed to `useForm({ defaultValue: { ... } })` +3. In the **field** `defaultValue` config parameter (either passed as prop to `` prop or declared inside a form schema) +4. If no default value is found above, it defaults to `""` (empty string) + +### As a prop on `` + +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 `` **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 + +``` + +### 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 + + +``` + +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 + + +``` + +### 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 ( + + ); +} +``` + +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 ( + + ); +} +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/field_hook.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/field_hook.mdx new file mode 100644 index 0000000000000..c7be88c4336a6 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/field_hook.mdx @@ -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 `` 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` +**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 + + {({ setValue }) => { + return setValue(e.target.value)} /> + }} + + +// You can use the "onChange" handler + + {({ onChange }) => { + return + }} + +``` + +### 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 . + +### 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` + +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. diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/form_component.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/form_component.mdx new file mode 100644 index 0000000000000..df479b5c72f37 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/form_component.mdx @@ -0,0 +1,79 @@ +--- +id: formLibCoreFormComponent +slug: /form-lib/core/form-component +title:
+summary: The boundary of your form +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +Once you have created , you can wrap your form with the `` 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 ( + + ... + + ); +}; +``` + +### FormWrapper + +**Type:** `React.ComponentType` +**Default:**: `EuiForm` + +This is the component that will wrap your form fields. By default it renders the `` component. + +Any props that you pass to the `
` component, except the `form` hook, will be forwarded to that component. + +```js +const MyFormComponent = () => { + const { form } = useForm(); + + // "isInvalid" and "error" are 2 props from + return ( + + ... + + ); +}; +``` + +By default, `` wraps the form with a `
` element. In some cases semantic HTML is preferred: wrapping your form with the `
` 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 element +const FormWrapper = (props: any) => ; + +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 ( + + ... +
+ ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/form_hook.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/form_hook.mdx new file mode 100644 index 0000000000000..d66c0d867c275 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/form_hook.mdx @@ -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 . + +**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 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.isValid === false && ( +
Only show this message if the form validity is "false".
+ )} +
+ ); +} +``` + +* 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(); + + 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 ( +
+ + + + + {hasErrors &&
Form is invalid.
} + + ); +}; +``` + +### 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` + +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' }]); +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/fundamentals.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/fundamentals.mdx new file mode 100644 index 0000000000000..4c16bed017b04 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/fundamentals.mdx @@ -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 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: + +- hook to declare a new +- component that will wrap your form and create a context for it +- component to declare a + +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 ( +
// 2 + // 3 + + + + + ); +}; +``` + +1. We use the `useForm` hook to declare a new form. +2. We then wrap our form with the `
` component, providing the `form` that we have just created. +3. Finally, we declared two fields with the `` 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['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 ( + + ... + + + ); +}; +``` + +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 `` has a fallback mechanism to render an `` 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 how the the `` renders. We will see that in a future section. diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_array.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_array.mdx new file mode 100644 index 0000000000000..b92880fdf806d --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_array.mdx @@ -0,0 +1,85 @@ +--- +id: formLibCoreUseArray +slug: /form-lib/core/use-array +title: +summary: The perfect companion to generate dynamic fields +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +Use the `` 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 on how to use ``. + +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 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 . + +### children + +**Type:** `(formFieldArray: FormArrayField) => JSX.Element` + +The children of `` 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 diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx new file mode 100644 index 0000000000000..b1d70d05c8d27 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx @@ -0,0 +1,397 @@ +--- +id: formLibCoreUseField +slug: /form-lib/core/use-field +title: +summary: Drop it anywhere in your
and see the magic happen +tags: ['forms', 'kibana', 'dev'] +date: 2021-04-14 +--- + +To declare a field in the form you use the `` 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 + + + + +// 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 (). + +### config + +**Type:** `FieldConfig` + +The field configuration. + +**Note**: In some cases it makes more sense to declare all your form fields configuration inside a 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 = { + label: 'Name', + validations: [ ... ], +}; + +export const MyFormComponent = { + const { form } = useForm(;) + return ( + + + + ); +}; +``` + +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 `` is paired with one of that wrap the EUI form fields, this prop is forwarded to the `` `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 `` 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 ''; + +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 ( +
+ + + + ); +}; +``` + +The above example could be written a bit simpler with a form schema and . + +```js +import { Form, getUseField, Field, FIELD_TYPES } from ''; + +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 ( +
+ + + + ); +}; +``` + +#### 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 (`false` by default) is set to `true`. + +**Note:** There are already many . 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 | Promise` + +A validator function to execute. It can be synchronous or asynchronous. + +**Note:** Have a look a 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. . + +##### 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 ( +
+ + + ) +} +``` + +#### 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:** `[]` + +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. . + +### 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 . + +### 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 ``, it will try to read the default value on . If you want to prevent this behaviour you can set `readDefaultValueOnForm` to false. This can be usefull for dynamic fields, as . + +### onChange + +**Type:** `(value:T) => void` + +With this handler you can listen to the field value changes. 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) => JSX.Element` + +The (optional) children of `` is a function child which receives the . 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 `` for all your fields. (e.g. using the [the `Field` component](../helpers/components#field) everywhere). + +You can use the `getUseField` helper to get a `` component with predefined props values. + +```js +const UseField = getUseField({ component: Field }); + +const MyFormComponent = () => { + ... + return ( +
+ {/*You now can use it in your JSX without specifying the component anymore */} + + + ); +}; +``` + + +## Typescript value type + +You can provide the value type (`unknown` by default) on the component. + +```js + path="name" defaultValue="mustBeAString" /> +``` + +This has implication on the field config provided that has to have the same type. + +```js +const nameConfig:FieldConfig = { ... }; + + path="name" config={nameConfig} /> +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx new file mode 100644 index 0000000000000..17276f41b3dac --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx @@ -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 . + +## 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 ( +
+ + + ); +}; + +const ChildComponent = () => { + const [formData] = useFormData(); // no need to provide the form object + return ( +
...
+ ); +}; +``` + +### 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(); +``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_hook.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_hook.mdx new file mode 100644 index 0000000000000..21c77afd6dbce --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_hook.mdx @@ -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 , 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` + +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 => { + // "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({ onSubmit: onFormSubmit }); + +// JSX + +``` + +### defaultValue + +**Type:** `Record` + +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, . + +```js +const fetchedData = { firstName: 'John' }; +const { form } = useForm({ defaultValue: fetchedData }); +``` + +### schema + +**Type:** `Record` + +Instead of manually providing a `config` object to each ``, 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 { + user: { + firstName: { + defaultValue: '', + ... // other config + }, + lastName: { + defaultValue: '', + ... + }, + isAdmin: { + defaultValue: false, + } + } +}; + +export const MyComponent = () => { + const { form } = useForm({ schema }); + + // No need to provide the "config" prop on each field, + // it will be read from the schema + return ( +
+ + + + + ); +} +``` + +### 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 ''; + +// 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 ( +
+ + + {/* Show the street address when the toggle is "true" */} + {showAddress ? : null} + + + + ) +} +``` + +### 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 ( + 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 ( +
+ + {isInvalid &&
{errorMessage}
} +
+ ); + }} +
+); +``` + +#### 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": "" +} +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_multi_fields.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_multi_fields.mdx new file mode 100644 index 0000000000000..2a16b8e878be8 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_multi_fields.mdx @@ -0,0 +1,97 @@ +--- +id: formLibCoreUseMultiFields +slug: /form-lib/core/use-multi-fields +title: +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 `` with their child function, which is not very elegant. + +```js + + {maxValueField => { + return ( + + {minValueField => { + return ( + { + minValueField.setValue(minValue); + maxValueField.setValue(maxValue); + }} + /> + ) + }} + + ) + }} + +``` + +You can use `` 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 + path: 'minValue', + config: { + ... + } + }, + max: { + path: 'maxValue', + }, +}; + + + {({ min, max }) => { + return ( + { + min.setValue(minValue); + max.setValue(maxValue); + }} + /> + ); + }} + +``` + +## Props + +### fields (required) + +**Type:** `{ [fieldId: string]: UseFieldProps }` + +A map of field id to `` 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 `` 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 + fields={{ min: { ... }, max: { ... } }}> + ... + +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/dynamic_fields.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/dynamic_fields.mdx new file mode 100644 index 0000000000000..f2525d5a16fba --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/dynamic_fields.mdx @@ -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 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 ( +
+ + {({ items, addItem, removeItem }) => { + return ( + <> + {items.map((item) => ( + + + + + + + + + removeItem(item.id)} + iconType="minusInCircle" + aria-label="Remove item" + style={{ marginTop: '28px' }} + /> + + + ))} + + Add item + + + + ); + }} + + + + + Submit + + + ); +}; +``` + +## Validation + +If you need to validate the number of items in the array, you can provide a `validations` prop to the ``. 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 ( +
+ + {({ items, addItem, removeItem, error, form: { isSubmitted } }) => { + const isInvalid = error !== null && isSubmitted; + return ( + <> + + <> + {items.map((item) => ( + + + + + + + + + removeItem(item.id)} + iconType="minusInCircle" + aria-label="Remove item" + style={{ marginTop: '28px' }} + /> + + + ))} + + + + Add item + + + + ); + }} + + + + + Submit + + + ); +}; +``` + +## Reorder array items + +```js +export const DynamicFieldsReorder = () => { + const { form } = useForm(); + + const submitForm = async () => { + const { data } = await form.submit(); + console.log(data); + }; + + return ( +
+ + {({ items, addItem, removeItem, moveItem }) => { + const onDragEnd = ({ source, destination }: DropResult) => { + if (source && destination) { + moveItem(source.index, destination.index); + } + }; + + return ( + <> + + + + {items.map((item, idx) => { + return ( + + {(provided) => { + return ( + + +
+ +
+
+ + + + + + + + removeItem(item.id)} + iconType="minusInCircle" + aria-label="Remove item" + style={{ marginTop: '28px' }} + /> + +
+ ); + }} +
+ ); + })} +
+
+
+ + Add item + + + + ); + }} +
+ + + + Submit + + + ); +}; +``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/fields_composition.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/fields_composition.mdx new file mode 100644 index 0000000000000..260908f94a790 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/fields_composition.mdx @@ -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 (`
...
`) + +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 ( + <> + + + ); +}; +``` + +```js +// golf_cart_car.tsx + +const forRentConfig = { + label: 'The cart is for rent', + defaultValue: true, +}; + +export const GolfCartCar = () => { + return ( + <> + + + ); +}; +``` + +```js +// clown_mobile_car.tsx + +const milesConfig = { + label: 'Current miles', + defaultValue: 1.0, + serializer: parseFloat, +}; + +export const ClownMobileCar = () => { + return ( + <> + + + ); +}; +``` + +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 ; + }; + + const submitForm = () => { + console.log(form.getFormData()); + }; + + return ( +
+ + + + {model !== undefined ? renderCarConfiguration() : null} + + + + + Submit + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/listening_to_changes.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/listening_to_changes.mdx new file mode 100644 index 0000000000000..c99184f5a5c0e --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/listening_to_changes.mdx @@ -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 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 ( +
+ + + {JSON.stringify(formData)} + + ); +}; +``` +### 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 ( + <> + + + {JSON.stringify(formData)} + + ) +}; + +export const MyComponent = () => { + const { form } = useForm(); + + return ( +
+ + + ); +}; +``` + +## 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 ( +
+ {/* Changing the "name" field won't trigger a re-render */} + + + + {showAddress && ( + <> +

800 W El Camino Real #350

+ + )} + + ); +}; +``` + +## 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 ( +
+ + + ); +}; +``` + +## 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; + getData(): MyForm; +} + +const schema: FormSchema = { + 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({ 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 ( +
+ + + ); +}; + +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(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 ( + <> +

My form

+ + + Submit + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/serializers_deserializers.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/serializers_deserializers.mdx new file mode 100644 index 0000000000000..393711b393e0f --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/serializers_deserializers.mdx @@ -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 = { + 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({ + 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 ( +
+ + + + + + {/* We don't remove it from the DOM as we would lose the value entered in the field. */} +
+ +
+ + + + Submit + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/style_fields.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/style_fields.mdx new file mode 100644 index 0000000000000..db7c98772eddb --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/style_fields.mdx @@ -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 `` is a render prop component that returns a . +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 ...> + return ( +
+ 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 ( + + + + ); + }} + + + ); +}; +``` + +## 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 ``. +The component will receive the `field` hook as a prop and any other props you pass to `componentProps`. + +```js + +``` + +**Note:** Before creating your own reusable component have a look at which handle most of the form inputs of [the EUI framework](https://elastic.github.io/eui). \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx new file mode 100644 index 0000000000000..bbd89d707e4fe --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx @@ -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 ''; + +interface MyForm { + name: string; +} + +const nameConfig: FieldConfig = { + 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(); + + return ( +
+ path="name" config={nameConfig}> + {(field) => { + const isInvalid = !field.isChangingValue && field.errors.length > 0; + const errorMessage = !isChangingValue && errors.length ? errors[0].message : null; + + return ( + + + + ); + }} + + + ); +}; +``` + +**Note:** Before creating your own validator, verify that it does not exist already in our . + +## 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 = { + 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 = { + validations: [ + { + validator: ({ value }) => { + let isCanceled = false; + const promise: Promise & { 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(); + return ( +
+ path="name" config={nameConfig}> + {(field) => { + const isInvalid = !field.isChangingValue && field.errors.length > 0; + return ( + <> + + {isInvalid &&
{field.getErrorsMessages()}
} + + ); + }} + + + ); +}; +``` + +## 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 = { + 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({ onSubmit }); + + return ( +
+ 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 ( + <> + ({ label: v }))} + onCreateOption={onCreateOption} + onChange={onChange} + onSearchChange={onSearchChange} + fullWidth + /> + {!field.isValid &&
{errorMessage}
} + + + ); + }} + + + ); +}; +``` + +Great, but that's **a lot** of code for a simple tags field input. Fortunatelly the `` helper component takes care of all the heavy lifting for us. . diff --git a/src/plugins/es_ui_shared/static/forms/docs/helpers/components.mdx b/src/plugins/es_ui_shared/static/forms/docs/helpers/components.mdx new file mode 100644 index 0000000000000..1b35e41a98739 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/helpers/components.mdx @@ -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 `` and its corresponding EUI field. + +You can import these components and pass them to the `component` prop on your ``. + +```js +import { Form, useForm, UseField, TextField, ToggleField } from ''; + +export const MyFormComponent = () => { + const { form } = useForm(); + + return ( +
+ + + + ); +}; +``` + +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 ``, 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* +* JsonEditorField +* SelectField +* SuperSelectField +* MultiSelectField +* RadioGroupField +* RangeField + +(*) Currently the `` 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 + prop override + } + }} +/> +``` + +## `Field` + +There is a special `` component that you can use if you prefer. If you use this component, it will check and map to the corresponding component in the list above. If the type does not match any known component, a `` 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 component everywhere + return ( +
+ + + + + ); +}; +``` + +The above example can be simplified one step further with . + +```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 ( +
+ + + + + ); +}; +``` +## 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 `` helper component. + +```js +const tagsConfig: FieldConfig = { + 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({ onSubmit }); + + return ( +
+ path="tags" config={tagsConfig} component={ComboBoxField} /> + + + ); +}; +``` diff --git a/src/plugins/es_ui_shared/static/forms/docs/helpers/validators.mdx b/src/plugins/es_ui_shared/static/forms/docs/helpers/validators.mdx new file mode 100644 index 0000000000000..aba2d6dffb1ba --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/helpers/validators.mdx @@ -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 `` configuration, the validations are objects with 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 ''; + +const { emptyField } = fieldValidators; + +// Some validator expose a function that you need to call to receive the validator handler +const nameConfig: FieldConfig = { + 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! diff --git a/src/plugins/es_ui_shared/static/forms/docs/welcome.mdx b/src/plugins/es_ui_shared/static/forms/docs/welcome.mdx new file mode 100644 index 0000000000000..2d1156f403bff --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/welcome.mdx @@ -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**: + +* +* +* + +## 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: