📜 ⬆️ ⬇️

Rambler Group experience: how we started to fully control the formation and behavior of front-end React components


There are many ways to create a modern web application, but each team inevitably faces approximately the same set of questions: how to distribute front and back duties, how to minimize the appearance of duplicate logic - for example, when validating data, which libraries to use, how to ensure reliable and transparent transport between front and back and document the code.

In our opinion, we managed to implement a good example of a solution balanced in complexity and profit, which we successfully use in production based on Symfony and React.

What kind of data exchange format can we choose when planning the development of a backend API in an actively developing web product that contains dynamic forms with related fields and complex business logic?
')

All these formats did not completely cover our needs, so I had to write my combine harvester. Such an approach can provide highly effective solutions for any particular application, but it carries risks:


Therefore, it is good practice to use common and stable libraries (like the left-pad from npm) by rule - the best code is the one you never wrote, and you solved the business problem. Backend web solutions development in Rambler Group advertising technologies is conducted on Symfony. We will not dwell on all the components of the framework used, below we will talk about the main part, on the basis of which the work is implemented - the Symfony form . React and the corresponding library extending JSON Schema for WEB specificity - React JSON Schema Form - is used on the frontend.

General scheme of work:



This approach gives many advantages:


Let's look at the components and their interaction scheme in more detail.

Initially, JSON Schema allows you to describe primitive checks that can be done on the client, like binding or typing different parts of the scheme:

const schema = { "title": "A registration form", "description": "A simple form example.", "type": "object", "required": [ "firstName", "lastName" ], "properties": { "firstName": { "type": "string", "title": "First name" }, "lastName": { "type": "string", "title": "Last name" }, "password": { "type": "string", "title": "Password", "minLength": 3 }, "telephone": { "type": "string", "title": "Telephone", "minLength": 10 } } } 

To work with the front-end scheme, there is a popular React JSON Schema Form library, which provides the necessary add-ons for web development on the JSON Schema:

uiSchema - JSON Schema itself determines the type of parameters passed, but this is not enough to build a web application. For example, a field of type String can be represented as <input ... /> or as <textarea ... />, these are important nuances with regard to which you need to correctly draw the scheme for the client. To transfer these nuances, uiSchema also serves, for example, for the above JSON Schema, you can specify the visual web component of the following uiSchema:

 const uiSchema = { "firstName": { "ui:autofocus": true, "ui:emptyValue": "" }, "age": { "ui:widget": "updown", "ui:title": "Age of person", "ui:description": "(earthian year)" }, "bio": { "ui:widget": "textarea" }, "password": { "ui:widget": "password", "ui:help": "Hint: Make it strong!" }, "date": { "ui:widget": "alt-datetime" }, "telephone": { "ui:options": { "inputType": "tel" } } } 

Live Playground example can be viewed here .

With this use of the scheme, rendering on the frontend will be implemented by standard components of bootstrap in several lines:

 render(( <Form schema={schema} uiSchema={uiSchema} /> ), document.getElementById("app")); 

If the standard widgets that are supplied with bootstrap do not suit you and you need customization - for some data types you can specify your templates in uiSchema, at the time of this writing, string , number , integer , boolean are supported.

FormData - contains form data, for example:

 { "firstName": "Chuck", "lastName": "Norris", "age": 78, "bio": "Roundhouse kicking asses since 1940", "password": "noneed" } 

After rendering, the widgets will be filled with this data - useful for editing forms, as well as for some custom mechanisms that we added for related fields and complex forms, see below.

More information about all the nuances of setting up and using the sections described above can be found on the page of the plugin .

Out of the box, the library allows you to work only with these three sections, but for a full-fledged web application, you need to add some more features:

Errors - it is also necessary to be able to pass errors of various backend checks for drawing to the user, and errors can be either simple validation errors - for example, the uniqueness of a login when a user is registered, or more complex ones based on business logic - i.e. we should be able to customize their (errors) number and text of displayed notifications. For this, the Errors section was added to the transmitted data set, besides the ones described above - for each field a list of errors for drawing is defined here.

Action , Method - for sending the data prepared by the user to the backend, two attributes were added that contain the URL of the processing controller's backend and the HTTP delivery method

As a result, for communication between the front and backline json turned out with the following sections:

 { "action": "https://...", "method": "POST", "errors":{}, "schema":{}, "formData":{}, "uiSchema":{} } 

But how to generate this data on the back end? At the time of the creation of the system there were no ready-made libraries to convert the Symfony Form into JSON Schema. Now they have already appeared, but have their drawbacks - for example, LiformBundle treats JSON Schema fairly freely and changes the standard at its own discretion, therefore, unfortunately, I had to write my own implementation.

Standard symfony forms are used as the basis for generation. It is enough to use the builder and add the required fields:
Sample form
 $builder ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]); 


At the output, this form is converted into a type scheme:
JsonSchema example
 { "action": "//localhost/create.json", "method": "POST", "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" }, "formData": { "title": "", "description": "", "year": "", "genre": "" }, "uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "mainForm" } } 


All code that converts forms to JSON is closed and is used only in the Rambler Group, if the community has an interest in this topic, we will refactor it in the bundle format in our github repository .

Let's look at a few more aspects without the implementation of which it is difficult to build a modern web application:

Validation of fields


It is defined using the symfony validator , which describes the rules for object validation, an example of a validator:

 <property name="title"> <constraint name="Length"> <option name="min">1</option> <option name="max">255</option> <option name="minMessage">title.min</option> <option name="maxMessage">title.max</option> </constraint> <constraint name="NotBlank"> <option name="message">title.not_blank</option> </constraint> </property> 


In this example, constrain of type NotBlank modifies the schema by adding a field to the array of required schema fields, and constrain of type Length adds schema-> properties-> title-> maxLength and schema-> properties-> title-> minLength attributes that validation should already take into account on the front end.

Item grouping


In real life, simple forms are the exception to the rule. For example, a project may have a form with a large number of fields and giving everything in a solid list is not the best option - we need to take care of the users of our application:

The obvious solution is to divide the form into logical groups of control elements so that the user can more easily navigate and make fewer mistakes:

As you know, the capabilities of the Symfony Form out of the box are quite large - for example, forms can be inherited from other forms, this is convenient, but in our case there are disadvantages. In the current implementation, the order in JSON Schema determines the order of drawing a form element in the browser, inheritance may violate this order. One option was to group items, for example:

Sample Nested Form
 $info = $builder ->create('info',FormType::class,['inherit_data'=>true]) ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]); $builder ->add($info) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]); 


This form will be converted to the following form:

JsonSchema Nested Example
 "schema": { "properties": { "info": { "properties": { "title": { "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" } }, "required": [ "title", "description" ], "type": "object" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "info", "year", "genre", "available" ], "type": "object" } 


and corresponding uiSchema
 "uiSchema": { "info": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "ui:widget": "form" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "group" } 


This method of grouping did not suit us as the form for data begins to depend on the presentation and cannot be used, for example, in API or other forms. It was decided to use additional parameters in uiSchema without breaking the current standard JSON Schema. As a result, additional options of the following type were added to the symphonic form:

 'fieldset' => [ 'groups' => [ [ 'type' => 'base', 'name' => 'info', 'fields' => ['title', 'description'], 'order' => ['title', 'description'] ] ], 'type' => 'base' ] 

This will be converted to the following scheme:

 "ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] }, 


Full version of schema and uiSchema
 "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1989", "1990" ], "enumNames": [ "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "boolean", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" } 

 "uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] }, "ui:widget": "fieldset" } 


Since on the frontend side , the React library we use does not support this out of the box, we had to add this functionality ourselves. With the addition of the new element “ui: group”, we are able to fully control the process of grouping elements and forms using the current API.

Dynamic forms


What if one field depends on another, for example, a drop-down list of subcategories depends on the category selected?



Symfony FORM gives us the ability to create dynamic forms using Event's, but unfortunately, at the time of its implementation, this feature was not supported by JSON Schema, although in recent versions this feature has appeared . Initially, the idea was to give the entire list to the Enum and EnumNames object, based on which to filter the values:

 { "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [ "eccentric", "romantic", "grotesque" ], "enumNames": [ { "title": "sgenre.choice.eccentric", "genre": "comedy" }, { "title": "sgenre.choice.romantic", "genre": "comedy" }, { "title": "sgenre.choice.grotesque", "genre": "comedy" } ], "type": "string", "title": "label.genre" } }, "type": "object" } 

But with this approach, for each such element, you will have to write your processing on the frontend, not to mention the fact that everything becomes very complicated when these objects become several or one element depends on several lists. In addition, the amount of data sent to the frontend for correct processing and rendering of all dependencies grows strongly. For example, imagine drawing a form consisting of three fields interconnected - countries, cities, streets. The amount of initial data that needs to be sent to the frontend backend can upset thin clients, and, as you remember, we need to take care of our users. Therefore, it was decided to implement the dynamics by adding custom attributes:


The presence of SchemaID may seem like duplication - there is an action attribute, but here we are talking about sharing responsibility - the SchemaID controller is responsible for the intermediate update of the schema and UISchema , and the action controller performs the necessary business action - for example, it creates or updates an object and does not allow sending a part of the form because performs validation checks. With these additions, the scheme starts to look like this:

 { "schemaId": "//localhost/schema.json", "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [], "enumNames": [], "type": "string", "title": "label.sgenre" } }, "uiSchema": { "genre": { "ui:options": { "reload": true }, "ui:widget": "select", "ui:help": "title.genre" }, "sgenre": { "ui:widget": "select", "ui:help": "title.sgenre" }, "ui:widget": "mainForm" }, "type": "object" } 

In case of changing the “genre” field, the frontend sends the entire form with the current data entered to the backend, receives in response a set of sections necessary for drawing the form:

 { action: “https://...”, method: "POST", schema:{} formData:{} uiSchema:{} } 

and renders instead of the current form. What exactly changes after sending is determined by the back-up, the composition or the number of fields can change, etc. - any change required by the business logic of the application.

Conclusion


Due to a small expansion of the standard approach, we received a number of additional features that allow us to fully control the formation and behavior of front-end React components, build dynamic schemes based on business logic, have a single point of validation rules formation and the ability to quickly and flexibly create new VIEW parts - for example, mobile or desktop applications. Going into such bold experiments, you need to remember about the standard on the basis of which you work and maintain backward compatibility with it. Instead of React on the frontend, any other library can be used, the main thing is to write the transport adapter to JSON Schema and connect any form rendering library. Bootstrap worked well with React since we had experience with this technology stack, but the approach we have described does not limit you in choosing technologies. In place of symfony, there could also be any other framework that allows you to convert forms into the JSON Schema format.

Upd: you can see our report on Symfony Moscow Meetup # 14 about it from 1:15:00.

Source: https://habr.com/ru/post/417023/


All Articles