An App From Scratch: Part 8 – Creating New Templates Tool (US1-C3)

15 minute reading time; ~2860 words


Welcome and hello!

Today, we’re going to create a template creation tool. This post talks about creating the new interface elements needed, how we connect our frontend to backend with an API call, and some more conversation about pull request sizing.

Without further ado, let’s dive into it and build that tool!

If you haven’t read the previous posts, I’d suggest you start at An App From Scratch: Part 1 – What To Build. You can also find all the related documents and code here: Building Tailgunner.

What Do I See?

As you can see by the card below, there are a few things we need to solve to complete the tool needed. Based on the fact that there is a system diagram linked in the details, I’d guess there’s going to be a lot of details to work through.

What I Need To Do

Reviewing the system diagram and acceptance criteria, we can break down these tasks:

  • Create a button to open an add dialog
  • Create the add dialog in the list page
  • Create a route to save the new template
  • Connect the dialog save to the new route
  • Add the new template to the list display
  • Add a notification for the created template

Putting It Into Action

Create a button to open an add dialog

Personally, the best place for an add button is up in the top right corner. The first step is to tidy up the current header. If we look at the current header, we can see there’s a bunch of Tailwind styles embedded in it.

Vue
<template #header>
    <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
        Templates
    </h2>
</template>

As part of adding the button, I’m going to:

  • Move the header H2 styles to our css file.
  • Add the button to the header.
  • Add a flexbox so I can align the new button to the right side.
CSS
/* Template Header Elements */
.template_header{
    @apply flex justify-between items-center;
}

.template_header h2{
    @apply font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight;
}

/* Generic Button Styles */
button.add_button{
    @apply m-1 p-2 text-base text-black rounded bg-sky-400;
}

button.add_button:hover{
    @apply bg-sky-200;
}

Moving these styles to the CSS file means they won’t need to load on every single page load, and will also help to keep things consistent across different pages, since we now have one place to change that will update everywhere.

Vue
<template #header>
    <div class="template_header">
        <h2>
            Templates
        </h2>
        <button class="add_button" @click="btnClick('Add New Template')">
            Add New Template
        </button>
    </div>
</template>

Now with that done, we have a way to trigger the next piece we’re doing!

Create the add dialog in the list page

First, we’ll create some code to support the dialog.

We need some variables to track the state of the dialog (line 5), and what the state of the form in it is (line 6-8). We’ll also make a small function that will eventually save the template, but for now will just close the dialog (line 11-14).

To power this, we’re also going to use the ref function from Vue (line 2), so that the UI can automatically update when we make changes to these variables.

Vue
<script setup>
import { ref } from 'vue';

// Add dialog state and methods
const showDialog = ref(false);
const form = ref({
    title: '',
    errors: null
});

// This function will be used to actually create the form we request
const createTemplate = () => {
  showDialog.value = false;
};
</script>

Next, we’ll add the actual dialog, with a small form in it. We’re going to use Inertia’s form handling, so we’ll also make sure to add the right hooks in the code for that.

Breaking down some key lines:

  • Line 3: The v-if reacts to the state of the variable on line 5 above. If we set that variable to true, the dialog will appear, if we set it back to false, it’ll disappear again.
  • Line 6: This blocks the standard form submit action, and instead tells our browser to call the function we created on line 12 above.
  • Line 8: The value of this field will be linked to the variable we set up in line 7 earlier. When someone types into this field, the above variable is updated, and if the above variable is updated by something in the code, the field below will reflect the same value. This is called two-way binding.
  • Line 13: If our request to the server returns an error about this field, we’ll outline it in red. As well, on line 15-17, we actually show the error message. (That ?. operator is called optional chaining, a way of accessing properties that might not be set, without throwing errors.)
  • Line 19: Clicking the cancel button will set the variable controlling the dialog back to false, causing it to be hidden again.
  • Line 23: While the form is processing, the Save button will be disabled
Vue
<template> <!-- Ignore this line, it's just so the formatter shows the right colors -->

<div v-if="showDialog" class="dialog-overlay">
    <div class="dialog">
        <h3 class="title">Create New Template</h3>
        <form @submit.prevent="createTemplate">
            <input
                v-model="form.title"
                type="text"
                placeholder="Template Name"
                id="name"
                class="w-full p-2 border rounded mb-4"
                :class="{ 'border-red-500': form.errors?.title }"
            />
            <p v-if="form.errors?.title" class="text-red-500 text-sm mb-2">
                {{ form.errors.title }}
            </p>
            <div class="flex justify-end gap-2">
                <button class="cancel_button" @click="showDialog = false">Cancel</button>
                <button
                    class="add_button"
                    type="submit"
                    :disabled="form.processing"
                >Save</button>
            </div>
        </form>
    </div>
</div>

Along with the dialog code itself (which I placed just before the <AppLayout title="Templates">), we also need a few styles. Below, we’ve set up an overlay that will cover everything on the screen, then placed the dialog content above that. (Same as last post, you can read more in the Tailwindcss docs about what these do.)

CSS
/* Dialog Styles */
.dialog-overlay {
    @apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50;
}

.dialog {
    @apply bg-white p-6 rounded-lg shadow-xl max-w-md w-full m-4;
}

.dialog .title{
    @apply text-xl font-bold mb-4;
}

Finally, we’ll update the button to control template visibility.

Vue
<template #header>
    <div class="template_header">
        <h2>
            Templates
        </h2>
        <button class="add_button" @click="showDialog = true">
            Add New Template
        </button>
    </div>
</template>

And now, let’s get a look at what the dialog looks like!

Create a route to save the new template

The only thing we have to do is enable the store resource on our existing route. Later, we could move this to an actual /api/ endpoint, but that would mean other adjustments to our code as well, so we’ll leave that for now.

PHP
Route::middleware([
    'auth:sanctum',
    config('jetstream.auth_session'),
    'verified',
])->group(function () {
    Route::get('/dashboard', function () {
        return Inertia::render('Dashboard');
    })->name('dashboard');

    Route::resource('/templates', TemplateController::class)->only(['index', 'store']);
});

There’s a handy command you can run, that will show you all the routes Laravel has. I’ve cut out most of them, but here’s a small snippet showing the command and what it outputs.

Bash
$ php artisan route:list

  GET|HEAD  / ....................................................................................
  GET|HEAD  dashboard ............ dashboard
  GET|HEAD  login ................ login  Laravel\Fortify  AuthenticatedSessionController@create
  GET|HEAD  templates ............ templates.index  TemplateController@index
  POST      templates ............ templates.store  TemplateController@store

Next, you can see the function that we added to our controller. The validation (line 4-6) will check that we have passed in the correct data to create a new template, and if there are problems, will pass back specific error messages (which we set up our form for earlier). After that, we’re going to create the new template (line 9-12), and then pass back a JSON response that contains the new template information, as well as some data for a “flash” message.

PHP
public function store(Request $request)
{
    // Validate that we have the required data for a new template
    $validated = $request->validate([
        'title' => 'required|string|max:120',
    ]);

    // Make us a new template
    $template = Template::create([
        'title' => $validated['title'],
        'user_id' => auth()->id(),
    ]);

    // Pass back the data for the new template, and a flash message
    return response()->json([
        'template' => [
            'id' => $template->id,
            'title' => $template->title,
            'description' => '',
            'created_at' => $template->created_at,
            'last_used' => $template->created_at,
            'records' => 0,
        ],
        'flash' => [
            'success' => [
                'data' => [
                    'id' => $template->id,
                    'title' => $template->title,
                ]
            ]
        ]
    ]);
}

One small change is also required for the Template model. We need to set a few fields as fillable, so we can mass-assign them.

PHP
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Template extends Model
{
    protected $fillable = ['title', 'description', 'user_id'];

Here’s an example of the data that this endpoint is returning when we create a new template.

JSON
{
    "template": {
        "id": 99,
        "title": "A Test Template",
        "description": "",
        "created_at": "2025-01-13T12:00:00.000000Z",
        "last_used": "2025-01-13T12:00:00.000000Z",
        "records": 0
    },
    "flash": {
        "success": {
            "data": {
                "id": 99,
                "title": "A Test Template"
            }
        }
    }
}

Connect the dialog save to the new route

We’re going to use the Axios library to make our POST call, saving the data for our new template. If everything goes well, we’ll hide the dialog and clear out the form. If there is an error, we’ll pass it to the form so it can be displayed and (hopefully) fixed by the user.

JavaScript
// Save the template
const createTemplate = () => {
    axios.post('/templates', { title: form.value.title })
        .then(response => {
            // Clean up the form
            showDialog.value = false;
            form.value.title = '';
        })
        .catch(error => {
            form.value.errors = error.response.data.errors;
        });
};

Add the new template to the list display

This is an easy change! When we get a successful save response, we just add the returned template data to the top of the list of templates, which triggers an automatic update of the displayed table.

JavaScript
// Save the template
const createTemplate = () => {
    axios.post('/templates', { title: form.value.title })
        .then(response => {
            // Clean up the form
            showDialog.value = false;
            form.value.title = '';

            // Add the new template to the top of the list
            templates.value.unshift(response.data.template);
        })
        .catch(error => {
            form.value.errors = error.response.data.errors;
        });
};

Add a flash message for the created template

A flash message is a brief message, intended to communicate status or display a dismissible message. In our case, we’re going to add a flash message just above the table to show that the template was created, and give a link to edit it.

To do this, we’re going to set up a variable to hold the flash information (line 2), and a small function to get rid of it when the user dismisses it (line 3).

We’ll also need Vue’s watch() function (line 2), to keep an eye on the flash value and update the UI when it changes (line 10-18).

Finally, we need to update the flash value with the information we get back from the API call (line 32-35). Since we set up the watch on line 10, changing this value will immediately update the interface.

Vue
<script setup>
import { ref, watch } from 'vue';

...

// Check for and handle the flash message
const flash = ref(null);
const dismissFlash = () => {flash.value = null;};

watch(
    () => page.props.flash?.success,
    (newFlash) => {
        if (newFlash) {
            flash.value = newFlash;
        }
    },
    { immediate: true }
);

// Save the template
const createTemplate = () => {
    axios.post('/templates', { title: form.value.title })
        .then(response => {
            // Clean up the form
            showDialog.value = false;
            form.value.title = '';

            // Add the new template to the list
            templates.value.unshift(response.data.template);

            // Trigger our flash message
            flash.value = {
                message: response.data.message,
                data: response.data.template
            };
        })
        .catch(error => {
            form.value.errors = error.response.data.errors;
        });
};

In reviewing this task, the notification was specified to include two buttons: “Add Data” and “Edit Template”. In getting to the requirement, we’ve spotted one problem however… because the template doesn’t have any defined data fields, we can’t actually add any data to it.

If there were stakeholders, we’d have a quick chat with them before removing this button from the final implementation. Where I’m solo, I had a conversation with myself, and I approved this change.

Finally, below, you can see the UI code to handle the flash message, attached to a v-if directive that watches the value we set above. Using the data in the flash.data, we can set up the message and button to edit (for now, just showing an alert message).

Vue
<template> <!-- Ignore this line, it's just so the formatter shows the right colors -->

<div class="bg-white dark:bg-gray-800 overflow-x-auto shadow-xl sm:rounded-lg">
    <div v-if="flash" class="ilf_success">
        <span class="block sm:inline">
            Successfully created new template "{{ flash.data.title }}"
            <button 
                class="edit" 
                @click="btnClick(`Edit Template ${flash.data.id}`)"
            >Edit Template</button>
        </span>
        <button @click="dismissFlash" class="close">
            <span class="text-2xl">×</span>
        </button>
    </div>

    <table v-if="templates.length" class="style01">

With all that wired up, let’s have a look at the completed page!

Comparing it to the wireframe, it seems we got reasonably close.

The Last Little Tweak

We’re going to make one final ease-of-use fix. We don’t yet have this template tool linked, and I’ve had to keep navigating to it via URL. Let’s make a little change to add a link to this page in the main template AppLayout.vue.

Vue
<template> <!-- Ignore this line, it's just so the formatter shows the right colors -->

<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
    <NavLink :href="route('dashboard')" :active="route().current('dashboard')">
        Dashboard
    </NavLink>
    <NavLink :href="route('templates.index')" :active="route().current('templates.index')">
        Templates
    </NavLink>
</div>

All we had to do was copy the existing link, and update the referenced route and templates. As you may recall from earlier, we’re using Laravel’s resource controller function, which give us both default handlers in our controller and a default route structure.

This little change gives us a handy link to our tool!

Commit And Merge

Today’s branch name is US1-C3_build_add_template_dialog, and it’s going into this pull request, and merged in this commit. I’ve also created some unit tests for the above work, and a new testing configuration. One note I want to make about PR size (which I’ve talked about previously) is that this one is on the larger side of what I would consider acceptable to send to a reviewer.

The Pull Request

I’ve changed 9 files, and about 350 lines of code. To me, the best way to break this up would have been to:

  • Submit the non-critical change separately (adding a test environment, adding a link to the page, even the model adjustment if done before the main code).
  • Break up the functional changes into pieces. Some possible break points are:
    • once we had the Add New Template button.
    • again after we’d added the route.
    • before we added the flash message.
    • a final one to capture any other changes to the code.

This separation would allow code to stream into the main branch faster and earlier, and would make reviewing each PR review shorter and easier. That said, this size of PR isn’t unreasonable either, because it gives a complete overview of the feature, but it would take a reviewer longer to process.

A New Test Environment

One of the things that was annoying me was every time I ran the test suite, it would wipe all my development data, since it was using the same database. To fix that, I added a new testing database, which I added to my PHPUnit config, and created a new .env file for.

phpunit.xml
        <env name="TELESCOPE_ENABLED" value="false"/>

        <env name="DB_DATABASE" value="tailgunner_testing"/>
        <env name="DB_CONNECTION" value="mysql"/>
    </php>
</phpunit>
.env.testing
# Database setup for test environment
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=tailgunner_testing
DB_USERNAME=root
DB_PASSWORD=

With these small changes, I can now run the tests against a specific testing database. (--env is to specify the environment, while --filter is to just run the one suite of tests.)

Bash
$ php artisan test --env=testing --filter TemplateControllerTest

   PASS  Tests\Unit\Controllers\TemplateControllerTest
   index returns user templates                    13.38s
   store creates new template                       0.24s
   store validates title length                     0.07s
   store validates empty title                      0.13s
   index requires authentication                    0.11s
   store requires authentication                    0.12s
   store returns correctly formatted response       0.15s

  Tests:    7 passed (48 assertions)
  Duration: 14.35s

Wrapping Up

At the end of this post, we’ve shown the process of creating a new interactive element, and touched on some code organization lessons along the way. We’re starting to get a feel for the structure of our app, and how all the pieces connect together.

The work for today’s post took longer than expected. Initially, I had figured this would take an hour or two, but the biggest challenge that derailed me was working out how best to connect the create dialog to the backend API (the initial model I had in my head turned out to be wrong, so I lost a few hours learning and working out how to connect things properly).

This is one of the fun things about engineering and why estimates are not as precise as we’d like them to be. Overall, I spent about a half-day writing the code, and another few hours organizing and writing this post.

With that, I hope you learned something new! Look for the next part soon: Setting up our production server and deployment (US1-C4)

Let me know what you think of how the tool looks, or anything else from this post!

Cheers!


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *