I recently stumbled my way into AdonisJS and fell in love with the framework. My background is in Ruby on Rails with a ❤️ for Vue. But I been experimenting with AdonisJS to understanding how it works and how AlpineJS can be used to add a little bit of delight to the users.
Building forms
As an engineer I feel like my life has been a full of building forms. So I thought I would build some form pages to understand the framework lifecycle and how easily you can create components. In the end I want to have a sign in page with:
- Components for Form, Input, Button
- Disabling all inputs when submitting
- Disabling and showing spinner on button when submitting
Creating components
Components are a well documented concept in AdonisJS. Using edge templates you can separate out reusable components. The trick is to try and make these the components as dumb as possible.
Form component
Starting from something simple, you can create a form component to display consistent form behaviour. In this case it's not that special, but you can add your success or failure success flashMessages
here to have a consistent way of displaying errors.
- action: The route to use for form submission
- method: Form method to use for submission ( eg. PUT, POST, ... )
- classNames: Styling classes to apply to the form
// app/resources/views/components/form/base.edge
<form action="{{action}}" method="{{method}}" class="{{classNames}}">
{{ csrfField() }}
{{{ await $slots.main() }}}
</form>
TextField component
A textField component will be used to handle label and input field elements. I used this guide to create the text field component and styled it to my needs.
- label: Input label to use for display
- name: The name of the input field
- type: Input type ( eg. text, password, ... )
- flashMessages: Flash object received from AdonisJS
// app/resources/views/components/form/text_field.edge
<div>
<label>
{{ label }}
<input
id="{{ name }}"
name="{{ name }}"
type="{{ type ? type : 'text' }}"
@if(required)
required
@endif
@if(placeholder)
placeholder="{{ placeholder }}"
@endif
value="{{ flashMessages.get(name, '') || '' }}"
@if(flashMessages.has(`errors.${name}`))
class="text-red"
@end
>
</label>
@if(flashMessages.has(`errors.${name}`))
<p>{{ flashMessages.get(`errors.${name}`) }}</p>
@end
</div>
Button component
The button component is where we can finally see some AlpineJS directives coming into play.
Two things to note here,
- We are coupling alpine data to the button. If a
loading
variable is not set in a parent element then it will complain. - We are making the button component dumb so it doesn't mutate the state in anyway. By bubbling up the event it lets the parent decide how the event should be handled. In this case it could be simple as setting a
loading
value to true, but it could be more complicated in other cases.
- event: When the button is clicked what event name do we want to bubble up.
- loading: If the form/button is in a loading state
- text: The button text
- type: The type of button ( eg. submit, reset, ... )
// app/resources/views/components/app_button.edge
<button @click="$dispatch('{{event}}')" type="{{ type }}" :disabled="loading" >
<div x-show="loading" class="loader"></div>
{{ text }}
</button>
Putting it all together
In the sign in page we can now put all the components together to build up the page quickly. I like to use fieldset
as it provides a container to store the AlpineJS dataset.
For this example I am storing { loading: false }
as my only data variable. The data is mutated to true
when submit-event
occurs. A great feature of fieldset
is that I can disable all input fields just by disabling the fieldset
. Lastly we tell the button component what event to fire when clicked, in this case that is submit-event
.
@form.base({
action: route('auth/SessionsController.create'),
method: 'POST',
classNames: 'mt-6',
})
<fieldset x-data="{ loading: false }" @submit-event="loading = true" :disabled="loading" :aria-busy="loading">
<div class="mt-1">
@!form.textField({
label: 'Email address',
name: 'email',
type: 'email',
flashMessageS: flashMessages
})
</div>
<div class="mt-4">
@!form.textField({
label: 'Password',
name: 'password',
type: 'password',
flashMessageS: flashMessages
})
</div>
<div class="mt-4">
@!appButton({
event: 'submit-event',
type: 'submit',
text: 'Sign in',
})
</div>
</fieldset>
@end
Conclusion
So far it has been an interesting journey using AdonisJS. I am only at the beginning of trying to understand how AdonisJS works and how to get the best out of it. But so far love the work that the AdonisJS team has put into it especially with the v5 release.
Top comments (2)
Great post! Love seeing community around Adonis + tailwindcss + alpine.
One issue I have found with that type of loading buttons is if the form submision fails (for example due to a Adonis validator bad input): the loading variable stays true and the button gets stuck loading. Which could be a bit confusing for the user.
Have you found this issue? If yes: have you found a workaround?
Keep up the great content!
Oh interesting, are you doing an ajax call via AlpineJS for the submit ?
In this article the submit is done server side, so it will always do a refresh and the data will reset. So
loading
will get set back to false.I haven't tried to do ajax call via AlpineJS + fetch yet, only reason being is that I haven't found the best way to structure the code in a nice way to do that. But as long as you have a catch within the fetch you should be able to reset loading back to false.