00:00:06.140
Hello there! Today, I'll be talking about building dynamic forms in Rails.
00:00:12.540
I'm Santi, a software engineer from Uruguay, a small country in South America. I work at a global development company called Roostrap.
00:00:19.020
I've been working with Rails applications for the past six years and recently got involved in open source. You can find me at SantiTV on GitHub and as SantiTV6_ on Twitter.
00:00:25.740
Okay, let's start! The talk is titled 'All You Need to Know to Build Dynamic Forms'.
00:00:31.140
Today, I'll be using a real-world example that our team had to build at work. I will walk you through our implementation while sharing how we leveraged the Rails ecosystem to implement this non-trivial feature.
00:00:45.000
You might be wondering what a dynamic form is. I think of it as a form that can be configured in the database, and without the need for deployment, changes are reflected in the app. Simply put, the feature allows admins to build these forms dynamically through a user interface and use them in various parts of the app.
00:01:03.420
We won't focus on the admin interface for creating the forms. Instead, we'll talk about how to render these dynamic forms, handle submissions, validations, and more. Don't worry if the feature isn't completely clear yet; I'll give you the proper context before we dive into the solution.
00:01:16.619
After that, we'll see how to render a dynamic form, add evaluations, and save the data to complete the life cycle of a form. Finally, we'll tackle a more complex requirement that I call repeatable fields.
00:01:41.400
The key takeaways from this talk will be the design of the solution we implemented, which leverages the ActiveModel API. On top of ActiveModel, we used a design pattern called form objects. We even started a very simple gem for form objects called 'Yes,' which helps simplify the process. Additionally, you'll learn how to use different tools in the Rails ecosystem, such as Ruby Singleton class methods and Hotwire, to enable Rails-friendly solutions for more complex scenarios.
00:02:06.420
Now, I'll share some context about the requirements to ensure we're all on the same page. In my current project, we provide a platform for universities, enabling students to take courses online. This approach makes education more accessible to a wider audience, a need that became particularly important during the pandemic.
00:02:35.580
Given the nature of our app, universities often request customization of these forms to meet their specific needs, which is how this feature was born. They wanted forms with multiple steps, akin to a wizard, where each step could contain multiple sections and, within each section, multiple fields.
00:02:59.640
At the same time, the fields can be of different types, such as text, numeric, dropdowns, and radio buttons. Each field can define its validations, and there were more requirements. Here, you can see an example of how these features are displayed in our app. At the top, there are the steps, the current step being 'Personal Information,' with a section called 'About You' containing various fields of different types, each of which could have validations based on the admin's setup.
00:03:26.099
Don't panic if it seems overwhelming. In this talk, we'll narrow the scope to focus on the essentials. We'll use the form you're currently viewing, which has a single step and includes only a text area and a select field. This form's purpose is to submit a reference for an applicant, asking for strengths and weaknesses along with rating among peers.
00:03:41.100
Upon submission, it saves a new reference record to the database. If the evaluations don't pass, it renders the errors. This process is similar to what happens when running the Rails scaffold, but universities need customization. For instance, some may want to add more fields or change which fields are required.
00:04:03.959
So, what do we need to do to make this form dynamic? First, we need to manage the database configuration of the form as entered by the admins. To represent dynamic forms in a relational database, we can use a model called FormConfig. This model represents the entire form with a human-friendly identifier and has many FieldConfigs. The FieldConfig model belongs to FormConfig and contains attributes like 'name' (e.g., email), 'label' (to be displayed in the form), and 'type' (e.g., textarea or select).
00:04:46.020
In this example, it also has options in case it's a field type that allows it, such as a select field. The position is also important since no one wants their forms to render fields in random order. For this talk, we will use just one type of validation, the required validation, which is a Boolean that can be toggled by the admin. With this model in place, we can represent the form we will use as an example.
00:05:05.580
Here’s how the records for our example look in the database: You can see that the FormConfigs table has one record titled 'References,' while the FieldConfigs table has two records, both associated with the reference FormConfig. We have fields named 'Strengths and Weaknesses' and 'Rating,' with field types being 'textarea' and 'select', respectively. The select field has options ranging from 1 to 10, indicating its position in the form. The Boolean indicates that 'Strengths and Weaknesses' is a required field while 'Rating' is optional.
00:05:56.220
Now that we have the data structure, how do we bring dynamic forms to life? I would summarize this process into several steps. First, we need to render the fields defined by the form, which includes rendering the correct type of field in the appropriate order. Next, we need to handle form submissions at an endpoint, validating the submitted attributes on the backend.
00:06:41.520
On a successful submission, we save the data and redirect somewhere; if there are any errors, we render them and ensure we keep the entered values. As always in programming, there are numerous possible solutions. Some might immediately think of the need for a JavaScript framework like React when discussing dynamic functionality.
00:07:01.080
However, we wanted to find a solution that felt Rails-friendly. Our team consists of Ruby developers, and we enjoy working with Rails, but not so much with JavaScript, to be honest. But what does a Rails-friendly solution mean? For us, it means we could use mostly the same tools as with a normal form.
00:07:36.000
To clarify, we wanted the call to look similar to the Rails scaffold, so anyone showing up in the project could easily understand it without needing to learn a new approach to working with this codebase. Ideally, we would want a similar view to a normal form using the FormWithBuilder along with a similar controller that implements 'if resource.save' to either redirect or render errors.
00:08:13.620
As a general rule, it's a good idea to avoid business logic in controllers and views. Therefore, we encapsulate all business logic for dynamic forms within models or plain old Ruby objects. Now, let's look at how to render a dynamic form.
00:08:38.880
In this code, we can see how a normal form is rendered, which I'm sure looks familiar, as it's mostly what we get when running a Rails scaffold. We render a label, display the field, render the errors, and at the bottom, we have a submit button.
00:09:09.839
How do we render the dynamic form in a similar way? Well, as you can see at first glance, the structure of the code is quite similar; however, instead of hard-coding the attributes, we now have a loop to iterate through the field config records in the database and render each one.
00:09:28.680
In the form builder, we utilize public_send with the field type, so Rails renders the correct type of field. This means the field type stored in the database must be a recognized tag builder by Rails. It could be a text field, text area, select, or others; otherwise, if the type isn't a known tag builder, this code will raise an exception.
00:09:46.320
Next, let’s examine the arguments of the FormWith method. We use an object called 'Form' that replaces the standard 'Reference' model in a typical form. This object, 'Form,' acts as the model for our form builder and is used by Rails to pre-fill the values.
00:10:06.839
For this reason, it needs to define the dynamic attributes and also respond to the errors method, which we use to render the errors below each field. Notice that we also have to pass the URL where we want to submit the data. The idea is for this partial to be reusable across the app by receiving the URL as an argument, allowing it to submit the form to various endpoints.
00:10:47.460
So now we have to ask, what is this object I call 'Form'? Is it the FormConfig model we created earlier? Not quite, as it wouldn't make sense to use an Active Record model since we need the attributes to be defined dynamically based on the form configuration.
00:11:27.900
Fortunately, ActiveModel allows us to use the FormWithBuilder with plain old Ruby objects. ActiveModel provides modules that can enhance our objects and make them work seamlessly with other parts of Rails.
00:12:11.640
ActiveModel is designed to be used without Active Record. It consists of modules that can be added to Ruby classes to augment their behavior. By making your object compliant with the ActiveModel API, you gain access to other features in Rails, such as route generation, out of the box.
00:12:40.020
ActiveModel has many modules, but the default one implements the basic API required to integrate with Action Pack. This module is called ActiveModel::Model. By including this module in our plain Ruby object, we inherit functionalities like model name introspection, conversions, translations, and, most importantly, validations.
00:13:09.060
We can initialize our object with attributes, and ActiveModel handles it as a Rails model, providing validations and allowing us to use the FormWithBuilder. This is fantastic, and certainly Rails-friendly. Before continuing, I'd like to point out a few other modules that ActiveModel provides, which can be helpful in your day-to-day tasks.
00:13:48.720
Some modules include attribute methods, which allow you to define methods for all attributes of your object; callbacks, which provide facilities to add callbacks for certain operations; dirty, to track value changes; errors, to manage validations; and serialization, which helps with object serialization. This module, for instance, is used by the Rails JSON serializer, which is why we can call .to_json on our models.
00:14:11.340
As you can see, ActiveModel is very powerful. You can take advantage of it to prevent bloating your Active Record models by extracting logic into separate objects that don't need to be linked to a database. After this talk, I encourage you to explore these modules, as they could be beneficial in your daily work.
00:14:37.680
Here's how we can utilize ActiveModel: We have a plain Ruby class called 'DynamicForm' that includes the ActiveModel::Model module, allowing it to have validations. It can be initialized with attributes and utilized in the View using FormWithBuilder.
00:14:58.680
In the initialize method, we're setting the accessors for the dynamic attributes and then calling super with the attributes so that ActiveModel can associate them with the object. It's crucial to set the accessors before calling super; otherwise, it would raise an exception since the writers for the dynamic attributes wouldn't exist.
00:15:27.720
To create accessors for dynamic attributes, we iterate through them, and for each one, we call 'singleton_class.attribute_accessor' with the field name. Have you ever heard about Singleton class? It's a powerful method that all Ruby objects possess. It's often used in gems, including Rails.
00:16:04.680
When calling it on an object, you access the meta class of the instance, which belongs solely to that instance. This allows you to change or extend its behavior. So, anything declared in the Singleton class will take precedence over the actual class of the instance.
00:16:37.440
To set existing values, we add the attribute accessors for each field to the instance's meta class, and then calling super allows ActiveModel to take care of setting the values.
00:17:12.840
Now, if we want to add validations, we can use the 'validates' method to add class-level validations. However, we cannot use it straightforwardly since different instances of the class will define different attributes and validations.
00:17:44.160
Instead, we can leverage the Singleton class to introduce our dynamic validations. Notice that we use the same ActiveModel 'validates' method, but this time we call it on the Singleton class of the instance. This way, each dynamic form object will define the appropriate validations.
00:18:09.300
In our example, we only had the required validation. Therefore, we check if the field is configured to be required and add the presence validation accordingly.
00:18:31.440
Now that we've implemented both the view and the ActiveModel object, we need to solve the issue of handling form submissions. Here's the controller for a standard form in our example; it closely resembles a scaffold-generated controller.
00:18:54.720
The same controller can be adapted to use the 'DynamicForm' instead of the reference model directly. In the 'new' action, we initialize a dynamic form object, while in the 'create' action, we initialize the form as well, passing the reference model to it.
00:19:20.400
When we call 'save' on the dynamic form, it saves a new reference to the database. However, we can't simply call 'save' on the dynamic form object, as ActiveModel doesn't provide it. It doesn't know anything about persistence, so we must implement it ourselves.
00:19:48.180
By doing so, we are implementing what is known in the Rails community as the form objects design pattern. These objects handle all aspects of a form, including validation, saving multiple models, sending emails, and more. You can use this pattern not only with dynamic forms but also with standard forms.
00:20:12.120
As I mentioned earlier, we created a gem to implement the form object pattern called 'Yes,' which stands for 'Yet Another Active Form.' We named it so because there have been many attempts to create an official gem for form objects in the past.
00:20:36.360
For this presentation, I won't be using the gem, as I want to demonstrate what is needed to make dynamic forms work clearly. However, I encourage you to explore its source code; it's only 60 lines of code and can help you get started with form objects.
00:20:59.940
Returning to the 'create' action, we decided to pass the reference model to the form object instead of constructing it within the dynamic form class. This decision ensures that the dynamic form class remains unaware of any specific business logic, allowing it to be reused across the app.
00:21:34.380
If you have more complex requirements, like sending an email when the reference gets submitted or needing to anticipate which fields can be submitted, you might explore alternative implementations like composing form objects or storing data in a JSON attribute. However, let's set that aside for today.
00:22:08.400
As mentioned before, we need to implement the 'save' method to behave similarly to Active Record's 'save' method. The first step is to run validations, returning false if they fail. There's also an option to skip validations, similar to Active Record models. If all validations are passed or skipped, then we persist the model in the database.
00:22:46.620
There's also a built-in version of the method, such as what Active Record models have, which calls 'save!' instead of returning true or false. This approach raises an exception if the object is invalid, which is particularly useful when saving models within a transaction.
00:23:09.840
Now, you see the dynamic form in action: it performs backend validations, marking any fields with errors, maintaining the values across renders, and upon success, creating a new reference in the database.
00:23:40.680
Remember, this solution is not limited to the reference form; it can be utilized anywhere. Simply render the dynamic form partial with the dynamic form object and specify a URL for form submission, altering the corresponding controller to handle that form submission.
00:24:07.320
I aimed to keep the implementation as straightforward as possible for this talk, but it's important to note that it can be expanded to support more complex features.
00:24:31.620
Moreover, our product managers now want to implement a feature for repeatable fields in our app. In our team, we commonly refer to this feature as 'the final boss.' Repeatable fields involve showing an 'add another' button that, when clicked, generates a new repetition of the field in the form.
00:25:09.900
Initially, we anticipated that adding this feature wouldn't be trivial. The Rails documentation explicitly states that if you want to add fields dynamically when a user clicks an 'add' button, Rails does not provide built-in support for this. There are gems available, like Cocoon, but we felt it wasn't worth adding a dependency that might include features we didn’t need.
00:25:41.820
What are our options? We could either create fields up front and show or hide them via JavaScript, or insert and remove DOM nodes using JavaScript. We opted to build the fields ahead of time to keep the rendering strictly on the backend.
00:26:10.680
The downside of this approach is that it limits the number of repetitions. Additionally, when the remove button is clicked, we need to not only hide the field but also clean up the field. Hence, some JavaScript is still required, though it's mostly just for hide-and-show logic.
00:26:54.960
At the same time, some teammates explored long-term alternatives not just for this feature but because we began identifying the need for more complex front-end functionalities that aren’t straightforward in vanilla Rails.
00:27:11.820
They developed a proof of concept using React and Redux, which was promising. But before we reached a conclusion, Hotwire was released, and they quickly began creating a similar proof of concept to compare both solutions.
00:27:44.460
The results were impressive: the Hotwire proof of concept was implemented with significantly less code and in much less time. It's unsurprising that the Hotwire solution was more Rails-friendly, leading us to discard React in favor of integrating Hotwire into our stack.
00:28:07.680
Does this mean we can eliminate our custom JavaScript solution for repeatable fields and instead leverage Hotwire? Let's take a closer look.
00:28:30.480
Hotwire comprises three frameworks: Turbo, Stimulus, and Strada. Turbo provides various techniques for making our apps behave like single-page applications. Stimulus is a JavaScript framework, while Strada is for mobile applications.
00:29:11.400
Since we're seeking a JavaScript-free solution, let's focus on Turbo, which offers four components: Turbo Drive, Turbo Frames, Turbo Streams, and Turbo Native. Turbo Drive enhances navigation, Turbo Frames helps decompose complex spaces, and Turbo Native targets native mobile applications. Turbo Streams allow us to send partial page updates from our server to the browser.
00:29:54.420
With Turbo Streams, we can manipulate the DOM using five modes: append, prepend, replace, update, and remove. Therefore, we can indeed replace our custom JavaScript solution with one using Turbo Streams.
00:30:27.120
Here's what we will do: when the 'add' button is clicked, Turbo intercepts the event and performs an HX request to the server. The server will render the new field, and once the response returns to the browser, Turbo will append it after the last field. In the same manner, each field will have a delete button next to it, enabling Turbo to issue an AJAX request that responds with a Turbo Stream, using the remove mode to specify that the element should be removed from the DOM.
00:31:09.000
This is how the view appears now: with code supporting repeatable fields, the only difference being that if the field is repeatable, we render a new partial where all the new logic is embedded.
00:31:23.760
In the repeatable field partial, we need to include a div with a unique ID wrapping all repetitions of this field. Inside this wrapper, we render the field multiple times with existing values. The wrapper will also be used by Turbo to know where to append additional fields as they're added dynamically.
00:31:44.880
Next to the input field, we include an 'add' button, which is a simple link directing to a new controller action tailored for dynamic forms. It specifies the field config that the server needs to render.
00:32:03.840
Here, a few important considerations arise: firstly, the link is enclosed in a Turbo Frame to prevent URL changes in the browser, and secondly, it utilizes a data attribute to ensure a Stimulus controller sets a header for the request.
00:32:20.640
This setup isn't ideal, but it's a workaround while waiting for Turbo to provide a cleaner solution, as Turbo is still in beta.
00:32:37.920
Examining the details of the repeater field partial, we notice it closely resembles the non-repeatable version. The distinction lies in using an option 'multiple: true,' allowing Rails to build the HTML field so that the values will be submitted as an array corresponding to this field.
00:32:56.460
The remove button is essentially another link that, unlike the 'add' button, uses the DELETE HTTP method. Consequently, no extra workarounds are necessary, making the implementation much cleaner.
00:33:27.120
Now that we have demonstrated how to add and remove buttons and render them, let's explore how these functionalities work on the back end. The controller handles requests triggered when the 'add' button is clicked. It initializes the corresponding objects, which will later populate the view.
00:34:04.740
The view file must use the .turbo_stream.erb extension, which notifies Rails that it needs to render this view because of the request format.
00:34:20.520
Within this view, we generate a Turbo Stream tag using the append action, which is a meta tag used internally by Turbo. Inside it, we include the actual HTML for our field.
00:34:56.820
We utilize fields_for to access the Rails form builder and render the field by reusing the same partial we previously discussed. Note that we are using a secure random UUID to uniquely identify each repetition since nothing else provides an identifier, as they're not stored in the database, and we don't track how many repetitions have been rendered.
00:35:23.640
This identifier will then be referenced when removing the corresponding repetition upon clicking the delete button.
00:35:48.600
Similarly, we have an action and a view for removing a field. The only difference here is that it sends a Turbo Stream meta tag with the remove action, indicating which DOM element should be removed.
00:36:11.460
We just refactored a solution, eliminating the need for custom JavaScript. Now, all logic resides on the backend, thanks to Hotwire.
00:36:45.960
While it’s true that we still use JavaScript with Turbo, the specifics are obscured from us. We can continue coding in Ruby, and I'm incredibly grateful that Hotwire has been released. It provides us with much more power while still maintaining our focus on server-side development, particularly within Rails.
00:37:08.400
Here’s the end result: the repeatable field called 'Other Comments' includes an 'add' button below it, and each repetition has a delete button next to it. When the delete button is clicked, a request appears in the network panel and is returned with a Turbo Stream using the remove mode, effectively eliminating the field from the DOM.
00:37:34.560
The same process occurs with the 'add' button, which issues an append mode request that appends the field in the DOM.
00:37:57.780
That wraps up our discussion! I hope you found this information helpful and are now equipped to leverage Rails effectively. Without a doubt, Rails is an incredibly powerful framework.
00:38:14.760
In summary, we explored how to implement a Rails-friendly solution for a non-trivial feature. The solution does not require any custom JavaScript, enabling Ruby developers to continue working in their preferred programming language.
00:38:36.300
The solution can be extended to support even more complex scenarios and is reusable throughout the application. We delved into the ActiveModel API, the Singleton class method, and Hotwire, combining all to implement a real-world use case.
00:38:57.720
I hope you now have new tools to add to your toolbox. Thank you for attending my talk, and enjoy the rest of the conference!
00:39:08.520
I'll share some resources that you might find helpful, including the sample app with a complete implementation.