Next, let's work on allowing our users to create a new challenge by adding a form.
Making A Create Page
First, we'll need a page, and if you'll recall back to our resourceful naming, convention states this should be /challenges/create.
node ace make:view pages/challenges/create # DONE: create resources/views/pages/challenges/create.edgeCopied!
// ... router.get('/challenges', [controllers.Challenges, 'index']) router.get('/challenges/:id', [controllers.Challenges, 'show']) router.get('/challenges/create', [controllers.Challenges, 'create']) // ...Copied!
- start
- routes.ts
Within the create method of our challenges controller, we'll render our new create page.
import type { HttpContext } from '@adonisjs/core/http' const challenges = [ { id: 1, text: 'Learn AdonisJS', points: 10 }, { id: 2, text: 'Learn EdgeJS', points: 5 }, { id: 3, text: 'Build an AdonisJS app', points: 20 }, ] export default class ChallengesController { // ... /** * Display form to create a new record */ async create({ view }: HttpContext) { return view.render('pages/challenges/create') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Let's add a link for ourselves on our challenges page to point to this form. While we're here, we can clean this up a little as well.
@layout() <div> <div class="hero"> <h1>Challenges</h1> @include('partials/challenge/available_points') <a href="/challenges/create" class="button">Create a new challenge</a> </div> @challenge.grid() @each(challenge in challenges) @!challenge.gridItem({ challenge }) @endeach @end </div> @endCopied!
Basic Form Submissions
Okay, great! Now onto our create page. Here we'll want a form with the fields our challenge requires.
textfor the name of the challengepointsfor the points our users get for completing the challenge
@layout() <div class="form-container"> <div> <h1> Create Challenge </h1> <p> Enter your challenge details below </p> </div> <div> <form action="/challenges" method="POST"> <div> <label> Text <input type="text" name="text" /> </label> </div> <div> <label> Points <input type="number" name="points" /> </label> </div> <div> <button type="submit" class="button"> Create Challenge </button> </div> </form> </div> </div> @endCopied!
- resources
- views
- pages
- challenges
- create.edge
Again, keeping with our resourceful naming, to store new records, we should send a POST request to /challenges. So, let's create that route next.
// ... router.get('/challenges', [controllers.Challenges, 'index']) router.get('/challenges/:id', [controllers.Challenges, 'show']) router.get('/challenges/create', [controllers.Challenges, 'create']) router.post('/challenges', [controllers.Challenges, 'store']) // ...Copied!
- start
- routes.ts
Next, for our controller, when we submit our form, the data within it will be sent up within our request's body. There are numerous ways we can get body data from our request. With request.all() it will simply return everything. Let's take a peek at what we're getting first.
import type { HttpContext } from '@adonisjs/core/http' const challenges = [ { id: 1, text: 'Learn AdonisJS', points: 10 }, { id: 2, text: 'Learn EdgeJS', points: 5 }, { id: 3, text: 'Build an AdonisJS app', points: 20 }, ] export default class ChallengesController { // ... /** * Handle form submission for the create action */ async store({ request }: HttpContext) { const data = request.all() console.log({ data }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Cross-Site Request Forgery
If we stop, fill out our form, and hit submit. You'll notice our browser refreshes and the form clears out. Let's check our server's log to see what we got!
WARN (21151): Invalid or expired CSRF token request_id: "fac1d913-f3a0-40d8-8da0-89996276225b" x-request-id: "fac1d913-f3a0-40d8-8da0-89996276225b"Copied!
What you'll find is a warning that the server recieved an "invalid or expired CSRF token," what's this about? CSRF stands for Cross-Site Request Forgery, and per OWASP:
"Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing"
To protect against this, our server generates a token with every request. When we submit POST, PUT, PATCH, or DELETE requests from our application, it will expect that token to be sent along with that request as verification that the request originated from our site, as a way to protect against these malicious attacks. This token is referred to as the CSRF Token, and AdonisJS provides a utility method, csrfField() we can call to plop a CSRF token field into our forms.
@layout() <div class="form-container"> <div> <h1> Create Challenge </h1> <p> Enter your challenge details below </p> </div> <div> <form action="/challenges" method="POST"> {{ csrfField() }} <div> <label> Text <input type="text" name="text" /> </label> </div> <div> <label> Points <input type="number" name="points" /> </label> </div> <div> <button type="submit" class="button"> Create Challenge </button> </div> </form> </div> </div> @endCopied!
- resources
- views
- pages
- challenges
- create.edge
Perfect! Let's try our form one more time. This time, you'll notice we get a blank page after we submit. This is because we aren't sending anything back as a response; we'll change that in a moment. First, though, let's check our log.
{ data: { _csrf: '6ysucdLo-pdCD18tWTyeS8qRubQarXYs13BQ', text: 'Test', points: '10' } }Copied!
Awesome, this time we got our body data, and we can even see our CSRF token coming through to boot.
Picking Body Data
Now, it is never a good idea to take in any data the user sends. Always be defensive when it comes to the data your application accepts from users. So, instead of using request.all() we can switch this to only accept the properties we expect. For this, again, we have a couple of options.
export default class ChallengesController { // ... /** * Handle form submission for the create action */ async store({ request }: HttpContext) { const data = request.only(['text', 'points']) const text = request.input('text') const points = request.input('points') console.log({ data, text, points }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
I want to note, though this is more defensive than using request.all() even these aren't ideal. We'll always want to validate user data. We'll cover that in the next lesson, so don't brush your hands off thinking you're done here.
import type { HttpContext } from '@adonisjs/core/http' const challenges = [ { id: 1, text: 'Learn AdonisJS', points: 10 }, { id: 2, text: 'Learn EdgeJS', points: 5 }, { id: 3, text: 'Build an AdonisJS app', points: 20 }, ] export default class ChallengesController { // ... /** * Handle form submission for the create action */ async store({ request, response }: HttpContext) { const data = request.only(['text', 'points']) challenges.push({ id: challenges.length + 1, ...data }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Typically, we would take in data and store it within a database for long-term storage. We aren't there yet, so we'll work with what we have at the moment, which is our in-memory array. Note, since it's in-memory, anything we add or remove to it will be undone when we restart our server, as that destroys the memory. So, let's pick our text and points values off our request and push them as a new challenge into our challenges array. We'll also assign it an incremented ID.
Handling Form Responses
So that we aren't met with a blank page again, we can forward our user back to the challenges page so we can see our updated list.
import type { HttpContext } from '@adonisjs/core/http' const challenges = [ { id: 1, text: 'Learn AdonisJS', points: 10 }, { id: 2, text: 'Learn EdgeJS', points: 5 }, { id: 3, text: 'Build an AdonisJS app', points: 20 }, ] export default class ChallengesController { // ... /** * Handle form submission for the create action */ async store({ request, response }: HttpContext) { const data = request.only(['text', 'points']) challenges.push({ id: challenges.length + 1, ...data }) return response.redirect('/challenges') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Responses are how we send instructions and data from our server back to the user. On our response, in our HttpContext is a redirect() method. This will alter our response to a 302, redirect status code with the Location header set to the path we've specified. The browser will take these instructions and use them to redirect the user to the challenges page.
Now, if we submit our form, not only will we be redirected, but we'll also see our new challenge added to our list!
Editing Data with a Form
Next, let's add the ability to edit a challenge by first getting our edit page created.
node ace make:view pages/challenges/edit # DONE: create resources/views/pages/challenges/edit.edgeCopied!
Then, we'll add two routes:
GET: /challenges/:id/editto render the edit formPUT: /challenges/:idto handle the updating
// ... router.get('/challenges', [controllers.Challenges, 'index']) router.get('/challenges/:id', [controllers.Challenges, 'show']) router.get('/challenges/create', [controllers.Challenges, 'create']) router.post('/challenges', [controllers.Challenges, 'store']) router.get('/challenges/:id/edit', [controllers.Challenges, 'edit']) router.put('/challenges/:id', [controllers.Challenges, 'update']) // ...Copied!
- start
- routes.ts
Next, we'll render our edit form within the edit method of our controller and update the challenge with the submitted data in our update method.
import type { HttpContext } from '@adonisjs/core/http' const challenges = [ { id: 1, text: 'Learn AdonisJS', points: 10 }, { id: 2, text: 'Learn EdgeJS', points: 5 }, { id: 3, text: 'Build an AdonisJS app', points: 20 }, ] export default class ChallengesController { // ... /** * Edit individual record */ async edit({ params, view }: HttpContext) { // find the challenge being edited by its id const challenge = challenges.find((row) => row.id === params.id) // pass the challenge to the view return view.render('pages/challenges/edit', { challenge }) } /** * Handle form submission for the edit action */ async update({ params, request, response }: HttpContext) { const data = request.only(['text', 'points']) // find the challenge being updated by its id, and update its data const challengeIndex = challenges.findIndex((row) => row.id === params.id) challenges[challengeIndex] = { id: params.id, ...data } return response.redirect('/challenges') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
We then need to fill out our page. We can copy/paste our create page as a starting point, updating "create" to "edit." We also need to pre-populate the field's values with the values of the challenge we're editing. Finally, we need to update our form's action to point to the specific challenge ID we're looking to update.
@layout() <div class="form-container"> <div> <h1> Edit Challenge </h1> <p> Enter your challenge details below </p> </div> <div> <form action="/challenges/{{ challenge.id }}" method="POST"> {{ csrfField() }} <div> <label> Text <input type="text" name="text" value="{{ challenge.text }}" /> </label> </div> <div> <label> Points <input type="number" name="points" value="{{ challenge.points }}" /> </label> </div> <div> <button type="submit" class="button"> Update Challenge </button> </div> </form> </div> </div> @endCopied!
- resources
- views
- pages
- challenges
- edit.edge
And, let's give ourselves a link to this page from our challenge's show page. I'm also going to add our "hero" class to our div as well.
@layout() <div class="hero"> <h1>{{ challenge.text }}</h1> <p>Points: {{ challenge.points }}</p> <a href="/challenges/{{ challenge.id }}/edit" class="button">Edit Challenge</a> </div> @endCopied!
- resources
- views
- pages
- challenges
- show.edge
HTTP Method Spoofing
If we now go to update one of our challenges, you'll notice we're met with a 404 exception.
Cannot POST:/challenges/1The keyword of note here is "POST". We've defined this route using resourceful conventions, so its HTTP Verb is PUT and not POST. So, how do we fix this? Your first thought might be to switch the method="POST" attribute on our form to method="PUT" and that'd be fantastic, if browsers supported that. Unfortunately, they only support GET and POST as form methods. Instead, we need to use HTTP Method Spoofing. This allows us to send our request from the form as a POST but have our server understand it as a PUT, PATCH, or DELETE instead.
In AdonisJS 7, this is enabled by default, but just in case, you can find this within your config/app.ts file.
/** * The configuration settings used by the HTTP server */ export const http = defineConfig({ // ... /** * Allow method spoofing via _method query parameter or form field. * Enables using PUT, PATCH, DELETE methods in HTML forms by spoofing * through POST requests with _method field. */ allowMethodSpoofing: true, // ... })Copied!
- config
- app.ts
As noted here, to use it, all we need to do is include a _method=PUT query string on our form action's URL.
@layout() <div class="form-container"> <div> <h1> Edit Challenge </h1> <p> Enter your challenge details below </p> </div> <div> <form action="/challenges/{{ challenge.id }}?_method=PUT" method="POST"> {{ csrfField() }} <div> <label> Text <input type="text" name="text" value="{{ challenge.text }}" /> </label> </div> <div> <label> Points <input type="number" name="points" value="{{ challenge.points }}" /> </label> </div> <div> <button type="submit" class="button"> Update Challenge </button> </div> </form> </div> </div> @endCopied!
- resources
- views
- pages
- challenges
- edit.edge
Now, if we attempt to update a challenge once more... voila! All works according to plan.