loading...

Rocket — becoming a known developer… and building an app to help make it happen — Part 6

mattkingshott profile image Matt Kingshott 👨🏻‍💻 Originally published at itnext.io on ・10 min read

Rocket — becoming a known (Laravel) developer… and building an application to help make it happen — Part 6

In this series, we will be reviewing the steps that we as developers need to take in order to create a suitable web presence and build an audience that we can communicate ideas, projects and commercial offerings with.

In addition to theory articles discussing marketing, presentation and methods of engagement, we will also be building Rocket — a Laravel application that will allow you and other developers to create a personal site and grow an audience through social media and article publishing… let’s dive in!

Today’s agenda

Now that we have the database foundation and basic UI in place for projects, we’ll fill in the remaining gaps by adding the missing CRUD functionality that is required to create, update or delete them.

Step #1 — Add the missing routes

First thing’s first. We need to add some routes to Rocket to enable the user to perform the CRUD functionality:

Route::post('/projects', 'ProjectController@store');
Route::get('/projects/create', 'ProjectController@create');
Route::patch('/projects/{project}', 'ProjectController@update');
Route::get('/projects/{project}/edit', 'ProjectController@edit');
Route::delete('/projects/{project}', 'ProjectController@destroy');

Combined with the original index route, these routes are the same as you would get if you created a resourceful controller.

Many developers like to use resourceful controllers, however I’m personally not a fan as I feel like I’m not really aware of all the routes in my application. Admittedly this is entirely a preference thing, it makes no material difference, so use whichever you prefer. We’ll stick with above approach.

Step #2 — Fill in the controller

Next, we’ll need to add the missing methods to the ProjectController. These will be responsible for rendering views and working with the database:

use App\Models\Project;
use App\Types\Controller;
use App\Requests\Project\StoreRequest;
use App\Requests\Project\UpdateRequest;

class ProjectController extends Controller
{
    /**
     * Create a new project.
     *
     */
    public function create()
    {
        return view('pages.projects.create.index');
    }

    /**
     * Edit an existing project.
     *
     */
    public function edit(Project $project)
    {
        return view('pages.projects.edit.index')
            ->with('project', $project);
    }

    /**
     * Delete an existing project.
     *
     */
    public function destroy(Project $project)
    {
        $project->delete();

        return $this
            ->response()
            ->notify('The project has been deleted')
            ->redirect('/projects');
    }

    /**
     * Store a new project.
     *
     */
    public function store(StoreRequest $request)
    {
        Project::create($request->validated());

        return $this
            ->response()
            ->notify('The project has been added')
            ->redirect('/projects');
    }

    /**
     * Update an existing project.
     *
     */
    public function update(UpdateRequest $request, Project $project)
    {
        $project->update($request->validated());

        return $this
            ->response()
            ->notify('The project has been updated')
            ->redirect('/projects');
    }
}

We’re taking advantage of route model binding to automatically load the Project model that we need, and we’re using Form Requests to handle the validation process before storing or updating a project.

Other than that, we’re calling the standard Eloquent methods to manage the project(s) within the database. Each of these methods should be familiar to anyone who has built a Laravel application in the past.

Step #3 — Validating user input

As previously mentioned, the store and update methods both use a set of form requests to verify that the input provided by the user meets the conditions that are required to create or update a project.

I’m a big fan of form requests as I like to extract validation (and authorisation) logic entirely from the controllers. This helps to keep them small and simple. Indeed, in our case, we’re executing a single line of code and then sending back a response. It makes scanning the code that much easier.

We still need to create the form requests, so let’s do that now:

php artisan make:request StoreRequest

php artisan make:request UpdateRequest

Since our authorisation logic is already set at the controller level, we can simply provide the validation rules. Once in place, Laravel will automatically validate any incoming data and throw an exception if the rules are broken.

Form.js will then process the errors and display the messages within the form fields making for a friendly experience when addressing any issues. Nice!

/**
 * Get the validation rules that apply to the request.
 *
 */
public function rules()
{
    return [
        'type' => 'bail|required|in:Commercial,Open Source',
        'name' => 'bail|required|string|min:1|max:100',
        'summary' => 'bail|required|string|min:1|max:255',
        'platforms' => 'bail|required|string|min:1|max:100',
        'url' => 'bail|required|string|min:1|max:255|url',
    ];
}

The rules enforce the data type restrictions we set in our migrations, as well as confirm that the URL to the project is an actual URL.

Since the rules are exactly the same for both the store and update methods, you could elect to use a single form request for both. I prefer not to do that as I believe it’s a better practice to be explicit.

Step #4 — Sprinkling CRUD actions

Now that the backend is done, we can move on to the user interface. We have our existing project list, so let’s add some buttons to enable the editing and deleting of these projects. We’ll also wrap them up in @auth tags so that only we can see them (since nobody else can perform these actions):

{{-- Auth --}}
@auth

    {{-- Form --}}
    <x-form type="delete"
            url="/projects/{{ $project -> id }}"
            class="w-full flex justify-end mt-6">

        {{-- Edit --}}
        <x-button color="blue"
                  align="right"
                  text="Edit"
                  class="mr-3"
                  action="Turbolinks.visit('
                      /projects/{{ $project -> id }}/edit'
                  )">
        </x-button>

        {{-- Delete --}}
        <x-button color="red"
                    align="right"
                    text="Delete"
                    action="Form.submit()">
        </x-button>

    </x-form>

@endauth

The edit button will send us to the edit page, while the delete button will submit the form the buttons sit within. This form will then send a delete request, which Laravel will parse and execute for us.

The edit and delete buttons in place

Next, we’ll need to add a button to allow for the creation of a new project. We can add this to the bottom of the list where we already have pagination links:

{{-- Footer --}}
<div class="w-full flex items-center mt-8 mb-6 {{ 
    auth()->guest() ? 'justify-end' : 'justify-between' 
}}">

    {{-- Auth --}}
    @auth

        {{-- Create --}}
        <x-button align="left"
                  color="green"
                  text="Create"
                  action="Turbolinks.visit('/projects/create')">
        </x-button>

    @endauth

    {{-- Pagination --}}
    {{ $projects->links() }}

</div>

As with the edit button, we’re simply instructing Turbolinks to send us to the create page. One thing worth noting though, is that depending on whether the user is a guest or not, we’re setting a flex configuration either to split the button and the links apart, or to push the links to the far right of the page.

The create button alongside the pagination links

Step #5 — Adding the forms

Our penultimate step, is to add forms to enable us to create a new project or to edit an existing one. For the article, we’ll focus on the edit form. The create form is exactly the same, but it uses a different route and has no values set:

{{-- Form --}}
<x-form type="patch"
        url="/projects/{{ $project -> id }}"
        class="w-full bg-gray-100 shadow-md rounded-lg p-6">

    {{-- Type --}}
    <x-dropdown k="id"
                v="id"
                id="type"
                class="mb-4"
                placeholder="Type"
                :value="$project->type"
                :items="collect([
                    ['id' => 'Commercial'], 
                    ['id' => 'Open Source'],
                ])">
    </x-dropdown>

    {{-- Name --}}
    <x-textbox id="name"
               class="mb-4"
               placeholder="Name"
               :value="$project->name">
    </x-textbox>

    {{-- Summary --}}
    <x-textarea id="summary"
                class="mb-4"
                placeholder="Summary"
                :value="$project->summary">
    </x-textarea>

    {{-- Platforms --}}
    <x-textbox id="platforms"
               class="mb-4"
               placeholder="Platforms"
               :value="$project->platforms">
    </x-textbox>

    {{-- URL --}}
    <x-textbox id="url"
               class="mb-4"
               placeholder="URL"
               :value="$project->url">
    </x-textbox>

    {{-- Button --}}
    <x-button text="Save"
              class="mt-4"
              align="right"
              action="Form.submit()">
    </x-button>

</x-form>

Let’s dig into this a little more and explore what’s going on:

  1. Our form uses a patch request to update an existing project.
  2. We have a couple of new components for dropdown and textarea fields. I won’t be reviewing the code for these here, so be sure to check out the repo to see how they work (don’t worry, it’s nothing fancy).
  3. We’re binding a simple collection to the dropdown in order to enforce that we can only select ‘Commercial’ or ‘Open Source’.
  4. Finally, we’re binding the attributes of the project to each of the fields so that the form is populated properly.

The edit project form

Step #6 — Testing that it works

The only remaining thing to do, is to confirm that everything we’ve now built works as expected. Since we’ve got interactive elements on our pages, we’re going to need some browser as well as server tests.

Since server tests are always faster, let’s try to confirm as much as possible with them and fill in the blanks with the browser tests later:

use App\Models\User;
use App\Models\Project;
use App\Types\ServerTest;

class ProjectTest extends ServerTest
{
    /** @test */
    public function a_user_can_create_a_project()
    {
        $user = factory(User::class, 1)->create()->first();

        $this->get('/projects/create')
            ->assertStatus(302);

        $this->actingAs($user)
            ->get('/projects/create')
            ->assertSuccessful()
            ->assertSee('Create Project');
    }

    /** @test */
    public function a_user_can_edit_a_project()
    {
        $user = factory(User::class, 1)->create()->first();
        $project = factory(Project::class, 1)->create()->first();

        $this->get("/projects/{$project->id}/edit")
            ->assertStatus(302);

        $this->actingAs($user)
            ->get("/projects/{$project->id}/edit")
            ->assertSuccessful()
            ->assertSee($project->name)
            ->assertSee($project->type)
            ->assertSee($project->summary)
            ->assertSee($project->platforms)
            ->assertSee($project->url);
    }

    /** @test */
    public function a_user_can_delete_a_project()
    {
        $user = factory(User::class, 1)->create()->first();
        $project = factory(Project::class, 1)->create()->first();

        $this->delete("/projects/{$project->id}")
            ->assertStatus(302);

        $this->actingAs($user)
            ->delete("/projects/{$project->id}")
            ->assertJsonFragment([
                'redirect' => '/projects',
                'notification' => [
                    'type' => 'success',
                    'fixed' => false,
                    'message' => 'The project has been deleted',
                ],
            ]);

        $this->assertCount(0, Project::get());
    }

    /** @test */
    public function a_user_can_store_a_project()
    {
        $user = factory(User::class, 1)->create()->first();
        $project = factory(Project::class, 1)->make()->first();

        $this->post('/projects', $project->toArray())
            ->assertStatus(302);

        $this->actingAs($user)
            ->post('/projects', $project->toArray())
            ->assertJsonFragment([
                'redirect' => '/projects',
                'notification' => [
                    'type' => 'success',
                    'fixed' => false,
                    'message' => 'The project has been added',
                ],
            ]);

        $this->assertCount(1, Project::get());
        $this->assertDatabaseHas('projects', $project);
    }

    /** @test */
    public function a_user_can_update_a_project()
    {
        $user = factory(User::class, 1)->create()->first();
        $project = factory(Project::class, 1)->create()->first();
        $new = factory(Project::class, 1)->make()->first();

        $this->patch("/projects/{$project->id}", $new->toArray())
            ->assertStatus(302);

        $this->actingAs($user)
            ->patch("/projects/{$project->id}", $new->toArray())
            ->assertJsonFragment([
                'redirect' => '/projects',
                'notification' => [
                    'type' => 'success',
                    'fixed' => false,
                    'message' => 'The project has been updated',
                ],
            ]);

        $this->assertCount(1, Project::get());
        $this->assertDatabaseHas('projects', $new);
    }
}

Whoa! That’s a lot of code… okay, let’s break it down. A decent amount of it is common to each test so let’s start with that:

  1. We’re creating a User, which we’ll need to authenticate with.
  2. We’re confirming that we’re redirected if we try to access any of the protected routes without being authenticated.
  3. We then access each of routes using the correct method type e.g. GET.

Now, let’s look at what is specific to each test:

  1. When we’re creating a user, all we need to do is confirm that our access to the route was successful and that we can see ‘Create Project’.
  2. When we’re editing a user, we’re confirming the attempt was successful and that the response contains each of the project’s attributes.
  3. When we’re deleting a user, we’re confirming that we received a ‘deleted’ notification and that the database no longer includes any projects.
  4. When we’re storing a user, we’re submitting the project attributes to the server, confirming we received an ‘added’ notification, and then checking for the presence of a project record that has the same attributes.
  5. When we’re updating a user, we’re doing the exact same thing as store, only we’re confirming that a different set of attributes has been used and that we received an ‘updated’ notification.

Step #7 — Adding browser tests

As mentioned earlier, since browser tests are slow, we only really want to add them when we have interactive elements on our page that need to be checked. In this case, we need to confirm the create and update forms, as well as the delete button (since it also uses a form to send a delete request):

use App\Models\User;
use App\Models\Project;
use App\Types\DuskTest;
use Illuminate\Support\Str;

class ProjectTest extends DuskTest
{
    /** @test */
    public function a_user_can_delete_a_project()
    {
        $this->browse(function(Browser $browser) {
            $user = factory(User::class, 1)->create()->first();
            $proj = factory(Project::class, 1)->create()->first();

            $browser->loginAs($user)
                ->visit('/projects')
                ->assertSee(Str::ucfirst($proj->name));

            $browser->press('#btn-delete')
                ->pause(500);

            $browser->assertPathIs('/projects')
                ->assertDontSee(Str::ucfirst($proj->name))
                ->assertSee('The project has been deleted');
        });
    }

    /** @test */
    public function a_user_can_store_a_project()
    {
        $this->browse(function(Browser $browser) {
            $user = factory(User::class, 1)->create()->first();
            $proj = factory(Project::class, 1)->make()->first();

            $browser->loginAs($user)
                ->visit('/projects/create')
                ->assertSee('Create Project');

            $browser->select('type', $proj->type)
                ->type('name', $proj->name)
                ->type('summary', $proj->summary)
                ->type('platforms', $proj->platforms)
                ->type('url', $proj->url)
                ->press('#btn-save')
                ->pause(500);

            $browser->assertPathIs('/projects')
                ->assertSee(Str::ucfirst($proj->name))
                ->assertSee('The project has been added');
        });
    }

    /** @test*/
    public function a_user_can_update_a_project()
    {
        $this->browse(function(Browser $browser) {
            $user = factory(User::class, 1)->create()->first();
            $proj = factory(Project::class, 1)->create()->first();
            $new = factory(Project::class, 1)->make()->first();

            $browser->loginAs($user)
                ->visit("/projects/{$proj->id}/edit")
                ->assertSee('Edit Project');

            $browser->assertSelected('type', $proj->type)
                ->assertInputValue('name', $proj->name)
                ->assertInputValue('summary', $proj->summary)
                ->assertInputValue('platforms', $proj->platforms)
                ->assertInputValue('url', $proj->url);

            $browser->select('type', $new->type)
                ->type('name', $new->name)
                ->type('summary', $new->summary)
                ->type('platforms', $new->platforms)
                ->type('url', $new->url)
                ->press('#btn-save')
                ->pause(500);

            $browser->assertPathIs('/projects')
                ->assertSee(Str::ucfirst($new->name))
                ->assertSee('The project has been updated');

            $browser->visit("/projects/{$proj->id}/edit")
                ->assertSelected('type', $new->type)
                ->assertInputValue('name', $new->name)
                ->assertInputValue('summary', $new->summary)
                ->assertInputValue('platforms', $new->platforms)
                ->assertInputValue('url', $new->url);
        });
    }
}

Since we’ve already written server-side tests for these actions, a lot of the code should be familiar, or at the very least, easy to interpret:

  1. When deleting a project, we login and confirm we can see the project on the main page. We then press the delete button, pause half a second, then confirm the presence of a notification and that the project cannot be seen.
  2. When storing a project, we login and visit the create page. We then fill in the form using the project attributes and hit the save button. We then confirm the presence of a notification and that the project can be seen.
  3. When updating a project, we login and visit the update page. We then check to make sure the existing project’s attributes are present in the form. After that, we replace them with the new set of attributes and hit save. We then confirm the presence of a notification and the revised project title.

Wrapping up

With this section finished, our projects feature is now complete!! Visitors can view what we’ve made and visit the associated sites, while we can create, edit, update and delete what exists in our portfolio.

In part #7, we’ll be moving on to something new. I hope you’re excited for it!

To ensure you’re notified when it comes out, why not go ahead and follow me here on Medium, or better yet, on Twitter, where I’ll also be posting additional updates as well as links to new articles.

Thanks, and have a great day! 😎

Posted on Apr 8 '19 by:

mattkingshott profile

Matt Kingshott 👨🏻‍💻

@mattkingshott

Founder. Developer. Writer. Lunatic. Created Pulse, IodineJS, Axiom, and more. #PHP #Laravel #Vue #TailwindCSS

Discussion

markdown guide