Dynamic Forms with VueJS
Part 22 of 48 in API Driven Development With Laravel and VueJSSo right now, we have the database structured to store a many to many relationship between cafes and brew methods and a way to retrieve them. We also have a parent child relationship for cafes and parent cafes. Now we need to adjust our form so we can account for these changes. With the parent child location relationship we will need an unknown amount of location fields so we will need a dynamic form. Luckily we are using VueJS which makes dynamic forms a breeze!
A heads up with this dynamic form. A lot of the functionality will be unique to roastandbrew.coffee. However, it’s good to see some examples on how this all ties together and hopefully it helps you on your project.
Step 1: Think about NewCafe.vue form edits
This will be an overhaul to the structure of the NewCafe.vue form. Let’s take a step back and think about what we are trying to accomplish.
We have cafes. These cafes have many children, and possibly one parent. Each cafe, whether parent or child has multiple brew types. Each cafe has a unique id whether parent or child that is a row in our cafes
table.
The difference between the parent and child cafes are:
1. Address
2. Location Name (unique to the location)
3. Brew Methods
What I think think we should do is in the form, make a button that adds a new location for each of the fields that is unique to the location. This will require a restructuring of the data being sent to the server. I’d say since we are still in beta, and don’t have 3rd party applications integrating with your API we don’t have to do a /v2 of the API quite yet. However, this will be one situation where you’d definitely want to version a route once you open it up publicly.
Step 2: Know What We Have To Tweak
So not only are we tweaking the form display by grouping the unique data and allowing the user to edit at will. We are also adjusting the Vuex module methods and api calls. In the Many to Many relationships tutorial https://serversideup.net/many-many-relationships-laravel/ I stated that we set up the Vuex modules to be ready to house the data for the brew methods. I followed the outline here for building a Vuex tutorial: Build a Vuex Module – Server Side Up
Besides the form, we will have to adjust:
* The dispatch to save a cafe
* The Javascript API to submit a cafe to the server
* Update validations on the server side to accept the new structure
Step 3: Begin Modifying NewCafe.vue
The first thing we want to do is remove the old address fields from the data()
method and add a locations: []
array. We will also want to remove the old address fields from the validations
and add a locations: []
array and a oneLocation
validation which I will explain as we go. Our new data()
method should look like:
data(){
return {
name: '',
locations: [],
website: '',
description: '',
roaster: false,
validations: {
name: {
is_valid: true,
text: ''
},
locations: [],
oneLocation: {
is_valid: true,
text: ''
},
website: {
is_valid: true,
text: ''
}
}
}
},
The locations array will hold all of the individual data for each location as we add it. The validations locations array will hold the validations for each location as we add it. This makes sure we don’t have a location added that has invalid data. The oneLocation
validation ensures at least one location has been added to the cafe.
Next, we will add a method called addLocation()
to our methods and call this when the component is created. The method should look like:
addLocation(){
this.locations.push( { name: '', address: '', city: '', state: '', zip: '', methodsAvailable: [] } );
this.validations.locations.push({
address: {
is_valid: true,
text: ''
},
city: {
is_valid: true,
text: ''
},
state: {
is_valid: true,
text: ''
},
zip: {
is_valid: true,
text: ''
}
});
},
What this does is push a location object to our locations array in the data with the fields for name, address, city, state, zip and another array for methods available which will be brew methods. We are really going to harness some sick VueJS reactivity power. The next part of the method, pushes validations for each of the new fields we are entering. We left out name and methods available for the following reason. First, if name is left blank, we will use the name field from the cafe which is still present, this field is not required. Second, when adding a cafe, you might not know all of the brew methods. That’s alright as well, these shouldn’t be required. When we do some more API tutorials and work with PUT method, we will write updating routes for the cafes.
Now, when the component is created, call the method to add a location like this:
created(){
this.addLocation();
},
This will initialize our first location.
Step 5: Add Template To Fill In Form
This is the huge step in modifying the data. We need a visual display for entering the fields. First, I’ll have you delete the existing fields that we moved to locations such as address, city, state and zip. Then I’ll have you add this chunk of code:
<div class="grid-container">
<div class="grid-x grid-padding-x" v-for="(location, key) in locations">
<div class="large-12 medium-12 small-12 cell">
<h3>Location</h3>
</div>
<div class="large-6 medium-6 small-12 cell">
<label>Location Name
<input type="text" placeholder="Location Name" v-model="locations[key].name">
</label>
</div>
<div class="large-6 medium-6 small-12 cell">
<label>Address
<input type="text" placeholder="Address" v-model="locations[key].address">
</label>
<span class="validation" v-show="!validations.locations[key].address.is_valid">{{ validations.locations[key].address.text }}</span>
</div>
<div class="large-6 medium-6 small-12 cell">
<label>City
<input type="text" placeholder="City" v-model="locations[key].city">
</label>
<span class="validation" v-show="!validations.locations[key].city.is_valid">{{ validations.locations[key].city.text }}</span>
</div>
<div class="large-6 medium-6 small-12 cell">
<label>State
<input type="text" placeholder="State" v-model="locations[key].state">
</label>
<span class="validation" v-show="!validations.locations[key].state.is_valid">{{ validations.locations[key].state.text }}</span>
</div>
<div class="large-6 medium-6 small-12 cell">
<label>Zip
<input type="text" placeholder="Zip" v-model="locations[key].zip">
</label>
<span class="validation" v-show="!validations.locations[key].zip.is_valid">{{ validations.locations[key].zip.text }}</span>
</div>
<div class="large-12 medium-12 small-12 cell">
<label>Brew Methods Available</label>
<span class="brew-method" v-for="brewMethod in brewMethods">
<input v-bind:id="'brew-method-'+brewMethod.id+'-'+key" type="checkbox" v-bind:value="brewMethod.id" v-model="locations[key].methodsAvailable"><label v-bind:for="'brew-method-'+brewMethod.id+'-'+key">{{ brewMethod.method }}</label>
</span>
</div>
<div class="large-12 medium-12 small-12 cell">
<a class="button" v-on:click="removeLocation( key )">Remove Location</a>
</div>
</div>
</div>
Definitely have some explaining to do! So what we did is we took the existing single location cafe information out of the form and added a dynamically created form where you can add as many locations to a cafe as you want and remove ones that get added by accident.
First, check out this line right off the bat:
<div class="grid-x grid-padding-x" v-for="(location, key) in locations">
What this does is loop over all of the locations in the locations array and grab the location and key. Remember in the last step, this is dynamically generated so every time you add a location VueJS uses it’s reactive powers to add a field to edit the data.
We will also use the key, to set the model on each of the form inputs. Let’s take a look at the Locations Name field:
<label>Location Name
<input type="text" placeholder="Location Name" v-model="locations[key].name">
</label>
See the model references locations[key].name
this references the location object at the key defined and the name associated with it. All of the other logic associated with location fields is the same. We can now validate this individual field and we have a VueJS model to display it.
Let’s check out a validation element next:
<span class="validation" v-show="!validations.locations[key].address.is_valid">{{ validations.locations[key].address.text }}</span>
This validates an address at a key. Now we can specifically display a validation for a dynamically added form field in VueJS.
The next chunk of code to examine is this:
<span class="brew-method" v-for="brewMethod in brewMethods">
<input v-bind:id="'brew-method-'+brewMethod.id+'-'+key" type="checkbox" v-bind:value="brewMethod.id" v-model="locations[key].methodsAvailable"><label v-bind:for="'brew-method-'+brewMethod.id+'-'+key">{{ brewMethod.method }}</label>
What happens here is we need an array of brew methods available at a location. We have the v-model
reference the location by key we are adding the brew methods to, but the one thing to point out is the id
I have the key being a combination if the id of the brew method and the key of the location to make sure they are unique.
Finally, check out the remove method:
<div class="large-12 medium-12 small-12 cell">
<a class="button" v-on:click="removeLocation( key )">Remove Location</a>
</div>
If you accidentally add too many locations, it’s nice to be able to remove a location. This calls a method called removeLocation
and passes the key of the location. The method splices the location array by the key so it’s completely removed. You should add the method to your methods
object like this:
removeLocation( key ){
this.locations.splice( key, 1 );
this.validations.locations.splice( key, 1 );
}
It not only removes the location but removes validation for the location as well.
Step 6: Include Brew Methods in the Form
Since we are iterating over the brew methods, it’d be a good idea to grab them from the data store. We loaded them up already in our Layout.vue on page load so they are already available. All we need to do now is add it to our computed variables like this:
computed: {
brewMethods(){
return this.$store.getters.getBrewMethods;
}
},
Now we have our brew methods available in our form.
Step 7: Validating the Dynamic Form
The most difficult part of validating a dynamic form is the unknown of how much data we have to validate. This happens since we can allow unlimited locations to a cafe. Luckily, we already prepped our form to handle these changes with the locations array and the locations array to validations.
After you have removed the individual validations for the address, city, state and zip, add the following code:
/*
Ensure all locations entered are valid
*/
for( var index in this.locations ) {
if (this.locations.hasOwnProperty( index ) ) {
/*
Ensure an address has been entered
*/
if( this.locations[index].address.trim() == '' ){
validNewCafeForm = false;
this.validations.locations[index].address.is_valid = false;
this.validations.locations[index].address.text = 'Please enter an address for the new cafe!';
}else{
this.validations.locations[index].address.is_valid = true;
this.validations.locations[index].address.text = '';
}
}
/*
Ensure a city has been entered
*/
if( this.locations[index].city.trim() == '' ){
validNewCafeForm = false;
this.validations.locations[index].city.is_valid = false;
this.validations.locations[index].city.text = 'Please enter a city for the new cafe!';
}else{
this.validations.locations[index].city.is_valid = true;
this.validations.locations[index].city.text = '';
}
/*
Ensure a state has been entered
*/
if( this.locations[index].state.trim() == '' ){
validNewCafeForm = false;
this.validations.locations[index].state.is_valid = false;
this.validations.locations[index].state.text = 'Please enter a state for the new cafe!';
}else{
this.validations.locations[index].state.is_valid = true;
this.validations.locations[index].state.text = '';
}
/*
Ensure a zip has been entered
*/
if( this.locations[index].zip.trim() == '' || !this.locations[index].zip.match(/(^\d{5}$)/) ){
validNewCafeForm = false;
this.validations.locations[index].zip.is_valid = false;
this.validations.locations[index].zip.text = 'Please enter a valid zip code for the new cafe!';
}else{
this.validations.locations[index].zip.is_valid = true;
this.validations.locations[index].zip.text = '';
}
}
What this does is iterate over all of the locations data and validates each piece. We iterate over it as an object in case the keys are out of order if we remove a location. Now each of the validations is keyed to the specific field in the array along with the text.
If you look at one of the lines, like:
this.validations.locations[index].zip.is_valid = false;
this.validations.locations[index].zip.text = 'Please enter a valid zip code for the new cafe!';
you will see that if one of the fields is invalid it’s keyed accordingly. This is how we target dynamic fields.
Another quick update on validations is we added a URL validation for the website field which is an additional field we added:
/*
If a website has been entered, ensure the URL is valid
*/
if( this.website.trim != '' && !this.website.match(/^((https?):\/\/)?([w|W]{3}\.)+[a-zA-Z0-9\-\.]{3,}\.[a-zA-Z]{2,}(\.[a-zA-Z]{2,})?$/) ){
validNewCafeForm = false;
this.validations.website.is_valid = false;
this.validations.website.text = 'Please enter a valid URL for the website!';
}else{
this.validations.website.is_valid = true;
this.validations.website.text = '';
}
This just checks to see if we have a website and if it’s a valid url. The rest of the validations stay the same and the overall process does too. We will return false if the form is not valid.
Step 8: Updating addCafe Dispatch
Now that we have a valid form with a whole bunch of new fields, we need to properly pass them to the action so we can save the new cafe.
In our method, submitNewCafe()
we will be adding the additional fields and restructuring our addresses so they are in the locations. Our updates should end up looking like:
submitNewCafe(){
if( this.validateNewCafe() ){
this.$store.dispatch( 'addCafe', {
name: this.name,
locations: this.locations,
website: this.website,
description: this.description,
roaster: this.roaster
});
}
},
We now pass all of the location data as an array and the website, description, roaster parameters we added later. Remember our brew methods are now specific to each cafe location.
Step 9: Updating cafe.js API
Once we have our addCafe
dispatch updated, we need to adjust the handler action method in the resources/assets/js/modules/cafes.js
Vuex module. Right now, this method accepts a data object, but passes the old structure to the API.
First, we need to update this to pass the new structure like so:
addCafe( { commit, state, dispatch }, data ){
commit( 'setCafeAddedStatus', 1 );
CafeAPI.postAddNewCafe( data.name, data.locations, data.website, data.description, data.roaster )
.then( function( response ){
commit( 'setCafeAddedStatus', 2 );
dispatch( 'loadCafes' );
})
.catch( function(){
commit( 'setCafeAddedStatus', 3 );
});
}
This passes the updated data to the api.
Now we need to open our /resources/assets/js/cafe.js
file and adjust the API to send the new data to the server.
The method being called is postAddNewCafe()
and we need to update the parameters and method payload like this:
/*
POST /api/v1/cafes
*/
postAddNewCafe: function( name, locations, website, description, roaster ){
return axios.post( ROAST_CONFIG.API_URL + '/cafes',
{
name: name,
locations: locations,
website: website,
description: description,
roaster: roaster
}
);
}
We now are passing the data in the right format from the JS side to the server to match what we now have in our dynamic form. Now we need to just update our server side validation to ensure we get valid data.
Step 10: Updating Server Side Validation
Right now, if we were to submit our form, we would get a load of validation issues because our request is being validated with the format of the old data. We need to ensure that the new data gets validated.
First, we open our /app/Http/Requests/StoreCafeRequest.php
file.
In the validation array, remove the old address data:
return [
'name' => 'required',
'website' => 'sometimes|url'
];
This has all been merged into locations. We also need to remove the old address data from the messages array.
Now we need to add the validations for the locations array. Laravel has some sick built in validation rules for arrays listed here: Validation – Laravel – The PHP Framework For Web Artisans.
What you can do is use the *
for any key and then validate each piece of the array. Our locations array validation should look like:
'name' => 'required',
'location.*.address' => 'required',
'location.*.city' => 'required',
'location.*.state' => 'required',
'location.*.zip' => 'required|regex:/\b\d{5}\b/',
'location.*.brew_methods' => 'sometimes|array',
'website' => 'sometimes|url'
Now any key in our locations array will be validated according to the rules. Pretty sick eh?
Step 11: Saving New Form Request
Now that we have valid data coming into our API end point it’s time to add the data to the database.
We will need to open our /app/Http/Controllers/API/CafesController.php
file and make some modifications to the postNewCafe
method.
Since we added multiple locations and the ability for a parent cafe (essentially used for grouping cafes), we need to make a lot of changes.
First let’s throw in all of our code and break it down:
$addedCafes = array();
$locations = $request->get('locations');
/*
Create a parent cafe and grab the first location
*/
$parentCafe = new Cafe();
$address = $locations[0]['address'];
$city = $locations[0]['city'];
$state = $locations[0]['state'];
$zip = $locations[0]['zip'];
$locationName = $locations[0]['name'];
$brewMethods = $locations[0]['methodsAvailable'];
/*
Get the Latitude and Longitude returned from the Google Maps Address.
*/
$coordinates = GoogleMaps::geocodeAddress( $address, $city, $state, $zip );
$parentCafe->name = $request->get('name');
$parentCafe->location_name = $locationName != '' ? $locationName : '';
$parentCafe->address = $address;
$parentCafe->city = $city;
$parentCafe->state = $state;
$parentCafe->zip = $zip;
$parentCafe->latitude = $coordinates['lat'];
$parentCafe->longitude = $coordinates['lng'];
$parentCafe->roaster = $request->get('roaster') != '' ? 1 : 0;
$parentCafe->website = $request->get('website');
$parentCafe->description = $request->get('description') != '' ? $request->get('description') : '';
$parentCafe->added_by = Auth::user()->id;
/*
Save parent cafe
*/
$parentCafe->save();
/*
Attach the brew methods
*/
$parentCafe->brewMethods()->sync( $brewMethods );
array_push( $addedCafes, $parentCafe->toArray() );
/*
Now that we have the parent cafe, we add all of the other
locations. We have to see if other locations are added.
*/
if( count( $locations ) > 1 ){
/*
We off set the counter at 1 since we already used the
first location.
*/
for( $i = 1; $i < count( $locations ); $i++ ){
/*
Create a cafe and grab the location
*/
$cafe = new Cafe();
$address = $locations[$i]['address'];
$city = $locations[$i]['city'];
$state = $locations[$i]['state'];
$zip = $locations[$i]['zip'];
$locationName = $locations[$i]['name'];
$brewMethods = $locations[$i]['methodsAvailable'];
/*
Get the Latitude and Longitude returned from the Google Maps Address.
*/
$coordinates = GoogleMaps::geocodeAddress( $address, $city, $state, $zip );
$cafe->parent = $parentCafe->id;
$cafe->name = $request->get('name');
$cafe->location_name = $locationName != '' ? $locationName : '';
$cafe->address = $address;
$cafe->city = $city;
$cafe->state = $state;
$cafe->zip = $zip;
$cafe->latitude = $coordinates['lat'];
$cafe->longitude = $coordinates['lng'];
$cafe->roaster = $request->get('roaster') != '' ? 1 : 0;
$cafe->website = $request->get('website');
$cafe->description = $request->get('description') != '' ? $request->get('description') : '';
$cafe->added_by = Auth::user()->id;
/*
Save cafe
*/
$cafe->save();
/*
Attach the brew methods
*/
$cafe->brewMethods()->sync( $brewMethods );
array_push( $addedCafes, $cafe->toArray() );
}
}
/*
Return the added cafes as JSON
*/
return response()->json($addedCafes, 201);
Lots of code to go through! In the first line, we initialize a new array called addedCafes()
. This will house the cafes we added to the database.
Next we grab all of the locations from the request and create a Parent Cafe:
$locations = $request->get('locations');
/*
Create a parent cafe and grab the first location
*/
$parentCafe = new Cafe();
$address = $locations[0]['address'];
$city = $locations[0]['city'];
$state = $locations[0]['state'];
$zip = $locations[0]['zip'];
$locationName = $locations[0]['name'];
$brewMethods = $locations[0]['methodsAvailable'];
We grab position 0, because with our validations we have 1 location being passed for sure and we create our parent cafe.
Now we save our parent cafe and sync the brew methods as shown in https://serversideup.net/many-many-relationships-laravel/
/*
Get the Latitude and Longitude returned from the Google Maps Address.
*/
$coordinates = GoogleMaps::geocodeAddress( $address, $city, $state, $zip );
$parentCafe->name = $request->get('name');
$parentCafe->location_name = $locationName != '' ? $locationName : '';
$parentCafe->address = $address;
$parentCafe->city = $city;
$parentCafe->state = $state;
$parentCafe->zip = $zip;
$parentCafe->latitude = $coordinates['lat'];
$parentCafe->longitude = $coordinates['lng'];
$parentCafe->roaster = $request->get('roaster') != '' ? 1 : 0;
$parentCafe->website = $request->get('website');
$parentCafe->description = $request->get('description') != '' ? $request->get('description') : '';
$parentCafe->added_by = Auth::user()->id;
/*
Save parent cafe
*/
$parentCafe->save();
/*
Attach the brew methods
*/
$parentCafe->brewMethods()->sync( $brewMethods );
After we add the parent cafe, we push the parent cafe to the addedCafes array:
array_push( $addedCafes, $parentCafe->toArray() );
This will be returned. Next is where things get a little unique. We check first to see if there are any more locations:
if( count( $locations ) > 1 ){
If there are, then we offset by 1 and add the other locations as individual cafes with the parent cafe set. This allows us to group the cafes. We offset by 1 since we already used the first location for the parent cafe. All of the other code is the same for saving the cafe and attaching brew methods.
Step 12: Add UX Features to Form
Theres a couple UX features I’m adding to the form. One is clearing the form on success and showing a notification. This is huge because if the user wants to add multiple cafes in one session, they don’t have to refresh the page. To do this, I first added the getCafeAddStatus
to the computed properties on the NewCafe.vue
page. This way we can listen to the changes.
computed: {
brewMethods(){
return this.$store.getters.getBrewMethods;
},
addCafeStatus(){
return this.$store.getters.getCafeAddStatus;
}
},
Next, we add the following watch method for the addCafeStatus
:
watch: {
'addCafeStatus': function(){
if( this.addCafeStatus == 2 ){
this.clearForm();
$("#cafe-added-successfully").show().delay(5000).fadeOut();
}
if( this.addCafeStatus == 3 ){
$("#cafe-added-unsuccessfully").show().delay(5000).fadeOut();
}
}
},
What this does is watch the addCafeStatus
. If it equals 2, then we clear the form and show the success message. The clear form method, resets the form data. We added that method in our methods here:
clearForm(){
this.name = '';
this.locations = [];
this.website = '';
this.description = '';
this.roaster = false;
this.validations = {
name: {
is_valid: true,
text: ''
},
locations: [],
oneLocation: {
is_valid: true,
text: ''
},
website: {
is_valid: true,
text: ''
}
};
this.addLocation();
}
At the very end, after it resets the information, it adds a location to the form.
If the cafe add status equals 3, then we show the unsuccessful added message.
Conclusion
This dynamic form gets a little more specific to the app itself, but hopefully the example can help those in need. VueJS does a damn good job with dynamic forms due to its reactive nature. Lots of functionality is finally coming together, but now we have a really nice base that we can abstract some data and go to town with even more tutorials and ideas.
Of course, check out GitHub – serversideup/roastandbrew: Helping the coffee enthusiast find their next cup of coffee. Also, helping aspiring web and mobile app developers build a single page app and converting it to a mobile hybrid. All tutorials can be found at https://serversideup.net for all of the source code and reach out for any questions!