Skip to content

Hook form lib#41658

Merged
sebelga merged 41 commits intoelastic:masterfrom
sebelga:feature/hook-form-lib
Sep 9, 2019
Merged

Hook form lib#41658
sebelga merged 41 commits intoelastic:masterfrom
sebelga:feature/hook-form-lib

Conversation

@sebelga
Copy link
Copy Markdown
Contributor

@sebelga sebelga commented Jul 22, 2019

Form library with hooks!

Motivation

In the Elasticsearch UI team we build many forms. Many many forms! 😊 For each of them, we manually define the form state & update and validate its value. Basically re-inventing the wheel for each new form. It takes our precious dev time to re-think the approach each time, but even more problematic: it means that each of our form is built slightly differently. Maintaining those forms means that each time we need to remember how the state is updated and how validation works for a specific form. This is far from efficient...

We need a system in place that takes care of the repetitive task of managing a form state and validating its value, so we can dedicate more time doing what we love the most: build amazing UX for our community! 😊

Main building blocks

useForm() hook & <UseField />

The main building blocks of the library are the useForm() hook and the <UseField /> component. The former is used to intantiate a form, you call it once and then forward the form object returned to the <UseField /> component. As you probably have guessed, <UseField /> is the Component you will use to declare a field on the form.

Let's go through some example:

// myForm.ts
import { useForm, Form, UseField, FormConfig } from "<path-to-elasticsearch-ui-shared>/static/forms/hook_form_lib";

export const MyForm = () => {
  const submit: FormConfig['submit'] = (formData, isValid) => {
    if (isValid) {
      // send Http Request or anything needed with the form data...
    }
  };

  const { form } = useForm({ onSubmit });

  return (
    <Form form={form}>
        <UseField path="title" />
        <button onClick={form.onSubmit}>Send form</button>
    </Form>
  );
};

That is all we need to create a form with a "title" property and listen for the input changes.
By default, <UseField /> will render an <input type="text" /> to hold the value. Of course, this is just to illustrate how the two building blocks work, in other examples we will see how to customize the UI.

Design decisions

Raw form state

In order to allow the most flexibility and also improve performance, the form data is saved as a flat object. Only when submitting the form, the output object is built and returned to the consumer. This is the reason why a form field is given a path and not a name to identify it.

// following the above example

return (
  <Form form={form}>
      <UseField path="book.title" />
      <UseField path="book.author" />
      <UseField path="city[0]" />
      <UseField path="city[1]"  />
      <button onClick={form.onSubmit}>Send form</button>
  </Form>
);

// internal form state
const rawFormData = {
  'book.title': 'Leviathan',
  'book.author': 'Paul Auster',
  'city[0]': 'New York',
  'city[1]': 'London'
}

// output
const output = {
  book: {
    title: 'Leviathan ',
    author: 'Paul Auster'
  },
  city: ['New York', 'London']
}

For array values, we would probably have dynamic rows, but this was just to illustrate how paths work. As you can see, updating the output form data shape is straightforward. Also, say goodbye to serialize and unserialize payloads for Elasticsearch. 😊

Performance

A lot of effort has been put in optimizing for performance. In our current forms, we usually have a state object with the form values and errors. On each keystroke, we update the state and thus re-render the whole form. On small form this is OK, but on large forms with many inputs, this is not efficient.

This is the reason why each form Field is wrapped inside its own component, managing its internal state of value + errors. This also implies that you can't directly access the form data and react to changes on a field value. Of course, there is a solution, have a look at "Access the form data" below.

Customize the UI

There are two ways to provide a custom UI:

Approach 1: Render props pattern

return (
  <Form form={form}>
    <UseField path="title">
      {(field) => {
        // field is the field object generated, is has several properties and methods you can use
        // to work with the field, but the mains ones are:
        // - onChange() --> method
        // - value --> property
        // - errors --> property

        const isInvalid = field.errors.length > 0 && form.isSubmitted;
        const errorMessage = field.errors.length ? (field.errors[0].message as string) : null;

        return (
          <EuiFormRow
            label="Some label"
            helpText="Some help text"
            error={errorMessage}
            isInvalid={isInvalid}
            fullWidth
          >
            <EuiFieldText
              isInvalid={isInvalid}
              value={field.value as string}
              onChange={field.onChange}
              isLoading={field.isValidating}
              fullWidth
            />
          </EuiFormRow>
        )
      }}
    </UseField>
    <button onClick={form.submit}>Send form</button>
  </Form>
);

As you can see, you have complete control on how a field is rendered in the DOM. The hook form library does not bring any UI with it, it just returns form and field objects to help building forms.
For our concrete Elasticsearch UI forms, this is a lot of boilerplate to simply render a text field with some error message underneath it. In a following PR I have prepared some goodies, let's call them "syntactic sugar Component" that will remove all the repetitive JSX so we can much more easily spot what is unique to a field (name, label, helpText, validation).

Approach 2: Passing a Component

Another way to customize the UI is to pass a Component as a prop.

// title_field.tsx

export const TitleField = ({ field, any, additional, prop }) => {
  const isInvalid = field.errors.length > 0 && form.isSubmitted;
  const errorMessage = field.errors.length ? (field.errors[0].message as string) : null;

  return (
    <EuiFormRow
      label="Some label"
      helpText="Some help text"
      error={errorMessage}
      isInvalid={isInvalid}
      fullWidth
    >
      <EuiFieldText
        isInvalid={isInvalid}
        value={field.value as string}
        onChange={field.onChange}
        isLoading={field.isValidating}
        fullWidth
      />
    </EuiFormRow>
  );
}
import { TitleField } from './title_field';

export const MyForm = () => {
  ...
  
  return (
    <Form form={form}>
      <UseField
        path="title"
        component={TitleField}
        componentProps={{ any: 1, additional: 2, prop: 3 }}
      />
  
      <button onClick={form.submit}>Send form</button>
    </Form>
  );
};

This has the benefit of separation of concern. On one side we build the form, declaring its fields, on the other, we build the UI for each field.

Field configuration

<UseField /> accepts an optional config prop to provide field configuration. This is where we will define the default value, formatters, (de)Serializer, validation...

Note: All the configuration settings are optional.

import { TitleField } from './title_field';

...

return (
  <Form form={form}>
    <UseField
      path="title"
      component={TitleField}
      defaultValue="C*T"
      config={{
        defaultValue: 'NEW YORK',
        // Transform the value before saving it in the form state.
        // Can have an unlimited number of chained formatters.
        formatters: [(value: string) => value.toUpperCase()],
        // Called right before returning the output object (onSubmit).
        // Most useful with Arrays where we might want to map and return only part
        // of the array item objects.
        serializer: (value: string) => value.replace(/A/g, '*'),
        // Called when intantiating the field default value.
        // Again, it is mostly useful for Arrays.
        deserializer: (value: string) => value.replace(/\*/g, 'A'),
        // Validation functions, explained in the section below
        validations: [],
      }}
    />

    <button onClick={form.submit}>Send form</button>
  </Form>
);

The first thing you see is that we have declared 2 default values. The prop defaultValue takes over the config one. This is because the config object might be declared statically in a separate file with a default value to be used in create mode. (for example for a toggle, if it's true or false by default). Then, on the field, when you are in edit mode, you want to override any default value from the config and provide the value that comes from the API request.

This is great, but as our form grows, declaring configuration object inline would quickly clutter our form component. A better way to declare the fields configuration is through a form schema.

Form Schema

A form schema is simply an object mapping to fields configuration. It means: cutting the inline configuration from the JSX above and putting it inside an object.

// myForm.schema.ts
import { FormSchema } from "<path-to-elasticsearch-ui-shared>/static/forms/hook_form_lib";

export const schema: FormSchema = {
  title: {
    defaultValue: 'NEW YORK',
    // Transform the value before saving it in the form state.
    // Can have an unlimited number of chained formatters.
    formatters: [(value: string) => value.toUpperCase()],
    // Called right before returning the output object (onSubmit).
    // Most useful with Arrays where we might want to map and return only part
    // of the array item objects.
    serializer: (value: string) => value.replace(/A/g, '*'),
    // Called when intantiating the field default value.
    // Again, it is mostly useful for Arrays.
    deserializer: (value: string) => value.replace(/\*/g, 'A'),
    // Validation functions, explained in the section below
    validations: [],
  }
}
// myForm.ts
import { useForm, UseField, FormConfig } from "<path-to-elasticsearch-ui-shared>/static/forms/hook_form_lib";
import { TitleField } from './title_field';
import { schema } from './myForm.schema';

export const MyForm = () => {
  const submit: FormConfig['submit'] = (formData, isValid) => {
    if (isValid) {
      // send Http Request or anything needed with the form data...
    }
  };

  const { form } = useForm({ onSubmit, schema }); // provide the schema

  return (
    <Form form={form}>
      <UseField path="title" component={TitleField} />
      <button onClick={form.submit}>Send form</button>
    </Form>
  );
};

And we're back to a nice, easy to read, <MyForm /> component! As you might have guessed, the UseField path maps to the schema object properties.

The schema does not need to be flatten though

const schema = {
  book: {
    title: {
      // field config (path = "book.title")
    },
    author: {
      // field config (path = "book.author")
    }
  }
}

Validation

We finally get to it! 😊 You validate a field by providing a set of Validation objects to the validation array of the field configuration. These Validation objects have one required property: a validator() function. This validator function will receive the value being validated, along with the formData in case we need to validate the value against other form fields.

The validator() functions can be synchronous or asynchronous (see below), and they will be executed sequentially until one of them fails. Failing means returning an Error object. If nothing is returned from the validator it means that it succeeded.

const schema = {
  title: {
    ...,
    validation: [{
      validator: ({ value }) => {
        if ((value as string).startsWith('.')) {
          return {
            code: 'ERR_FORMAT_ERROR',
            message: `The title can't start with a dot (.)`,
          };
        }
      },
    }]
  }
}

As we can see, field validator function are pure functions (when doing synchronous validation) that receive a value and return an Error object or void. As we know, a lot of the validation we do in our forms check for the same type of error (is the field empty?, does the string start with "x"?, is this a valid index pattern?, is this a valid time value?...). Those field validator functions are a good candidate for other pieces of reusable static code. In my separate "goodies" PR I will include some of them. The idea is that we grow our list of reusable field validators functions and only add business-specific validators inside the form schema whenever it does not make sense to make reusable.

// <path-to-reusable-form-field-validators>/starts_with_char.ts

export const startsWithCharField = (char: string) => ({ value }) => {
  if (value.startsWith(char)) => {
    return {
      code: 'ERR_FORMAT_ERROR',
      message: `The field value can't start with "${char}"`,
      char, // return any useful property to the error
    };
  }
}
import { startsWithCharField } from '<path-to-reusable-form-field-validators>/starts_with_char';

const schema = {
  title: {
    ...,
    validation: [{
      validator: startsWithCharField('.')
    }]
  }
}

Asynchronous validation

In some cases, we need to make an HTTP Request to validate a field value (for example to validate an index name). The good news is... asynchronous validation works exactly the same way as synchronous validation! You can mix both, the only thing to bear in mind is if one of the validation is asynchronous, calling field.validate() will need to be "awaited".

const schema = {
  title: {
    ...,
    validation: [{
      validator: async ({ value }) => {
        const response = await httpService.post('/some-end-point', { value })
        if (response.KO) {
          return {
            code: 'ERR_INVALID_INDEX_NAME',
            message: 'Sorry the name is not valid',
          }
        }
      }
    }]
  }
}

Default value object

We have seen earlier how we can provide a default value on the <UseField /> component through props:

<UseField path="title" defaultValue={myFetchedResource.title} />

If we only have a few fields, this is good enough. But if we have a complex form data object, this will quickly become tedious as we might have to drill down the myFetchedResource object to child components of our <MyForm /> main component.

To solve this, when initiating the form we can provide an optional defaultValue object. When a field is added on the form, if there is a value on this defaultValue object at the field path, it will be used as the default value.

export const MyForm = () => {
  ...

  const myFetchedResource = {
    title: 'Hello'
  }

  const { form } = useForm({ onSubmit, defaultValue: myFetchedResource });

  return (
    <Form form={form}>
      <UseField path="title" />
      <button onClick={form.submit}>Send form</button>
    </Form>
  );
};

Adding the "edit" capability to a form has never been so easy 😊

Dynamic form row with <UseArray />

In case your form requires dynamic fields, the <UseArray /> component will help you add and remove them.

export const MyForm = () => {

  ...

  return (
    <Form form={form}>
      <div>
        <UseArray path="users" >
          {({ items, addItem, removeItem }) => (
            <React.Fragment>
              <div>
                {items.map(item => (
                  <div key={row.id}>
                    <UseField path={item.path + '.firsName'} />
                    <UseField path={item.path + '.lastName'} />
                    <button type="button" onClick={() => removeItem(row.id)}>
                      Remove
                    </button>
                  </div>
                ))}
              </div>
              <button type="button" onClick={addItem}>
                Add row
              </button>
            </React.Fragment>
          )}
        </UseArray>
      </div>
      <button onClick={form.submit}>Send form</button>
    </Form>
  );
};

Access the form data with <FormDataProvider />

As mentioned above, for performance reason, you can't access the form data on the form object and get updates in your view when a field value change. That does not mean it is not possible to do. Whenever you need to react to a field value change, you can use the <FormDataProvider /> component.

return (
  <Form form={form}>
    <div>
      <UseField path="title" />

      <FormDataProvider pathsToWatch="title">
        {formData => <div>Name: {formData.title as string}</div>}
      </FormDataProvider>
    </div>
    <button>Send form</button>
  </Form>
);

The pathsToWatch is optional. It can be a path or an Array of paths. It allows you to declare which form field(s) you are interested in. If you don't specify the pathsToWatch, it will update on any field value change. For performance, you should always provide a value for it.

cc @cjcenizal

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

non-issue Indicates to automation that a pull request should not appear in the release notes release_note:skip Skip the PR/issue when compiling release notes Team:Kibana Management Dev Tools, Index Management, Upgrade Assistant, ILM, Ingest Node Pipelines, and more t// v7.4.0 v8.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants