DEV Community

Cover image for Laravel101: Exploring Efficient Task Management with Many-to-Many Relationships and Tags
Kazem
Kazem

Posted on

Laravel101: Exploring Efficient Task Management with Many-to-Many Relationships and Tags

Great to have you back! In our previous article, we talk about associating different models and defining relationships within our Laravel application. The best part is that now every user in our project can have personalized tasks.

In this new article, we’re going to take things a step further and explore the many-to-many relationship. We’ll introduce a brand new model called tags, which will enable each task in our application to have multiple tags.

But wait, there’s more! In this article, we're also going to create a fantastic search select blade component, designed to make working with tags inside our forms. So get ready, because we’re about to dive into some seriously exciting stuff!


Create a search select input in blade

Now, let’s talk about a new feature I’ve decided to add: a search select input for adding and selecting tags within the task forms. There might be some packages available that can help with this in the application, but let’s start by implementing it using pure JavaScript.

So, let’s begin!

First, I created a simple input component for searching text, located in the common directory of our blade components. Then, I added this input component to our create tasks view, as shown in the following picture:

Image description

Now, if we want to work with pure JavaScript within our common input, we usually need to define all the JavaScript functions within our app.js inside resources/js directory. However, there’s a more convenient way to do this that I prefer, and that is by using a stacked scripts. To achieve this, we have another helpful function within the blade syntax called push. You can use it like this:

@push('scripts')
<script type="module">
    // Your JavaScript code goes here
</script>
@endpush
Enter fullscreen mode Exit fullscreen mode

By using this syntax, you can define your script inside the @push('scripts') and @endpush tags, and it will be treated as a module. This makes it easier to manage and organize your JavaScript code within your application.

However, right now these scripts won’t work. In order to bring them to life, we need to add the stacked scripts inside our main layout:

Image description

Now if you open your console inside your browser you’ll see that our script works:

Image description

Right now, the first step is to listen to every change and character that the user enters in the input field. To achieve this, you need to assign an event listener to the input element.

To see this in action, you can add the following code snippet to your scripts. When you run it, you’ll notice that every character you enter in the input will be logged to your console:

@push('scripts')
<script type="module">
    let tagSearchInput = document.getElementById("search-tag");

    tagSearchInput.addEventListener("input", async function (e) {
        console.log(tagSearchInput.value);
    });
</script>
@endpush
Enter fullscreen mode Exit fullscreen mode

Now, let’s move on to the next step, which is finding all the tags based on the search input.

But wait we don’t even define our tag model inside our application! It’s actually quite simple! All you need to do is run the following command, and it will take care of creating the model, migration, and controller for you:

php artisan make:model Tag -mc
Enter fullscreen mode Exit fullscreen mode

This model is straightforward because it only has one attribute, which is the name. So, update the migration file by adding a simple name column. Similarly, update the request file as shown below:

Image description

Finally, let’s move back to the controller because this is where the new things happen, and we need to work on it.

First, let’s utilize our index function in a slightly different way. Instead of simply listing all the tags in the view, we’ll modify it to return a JSON response. This will allow us to retrieve all the data in our JavaScript code:

public function index()
{
   return response()->json([
        'tags' => []
   ]);
}
Enter fullscreen mode Exit fullscreen mode

Then we need to search inside our tags records to find tags based on searching input. To accomplish this, we can utilize a commonly used syntax in Laravel for searching within model. Laravel provides a useful function called where() that allows us to retrieve specific records from our database tables based on specific attributes or columns.

For instance, if we want to find a tag where its name is “job”, the query would look like this:

$tag = Tag::where('name', 'job')->get();
Enter fullscreen mode Exit fullscreen mode

Don’t forget to use get() at the end to execute the SQL command.

Now, what if we want to find all the tags that have a name containing the specified letter or letters? Here we use LIKE as a 2nd parameter and % at the beginning and end of our search key. For instance:

# all tags start with a 's'
$tags = Tag::where('name', 'LIKE', 's%')->get();

# all tags end with a 's'
$tags = Tag::where('name', 'LIKE', 's%')->get();

# all tags contains a 's'
$tags = Tag::where('name', 'LIKE', '%s%')->get();
Enter fullscreen mode Exit fullscreen mode

So our index function could be look like bellow:

public function index()
{
    $query = Tag::where('name', 'LIKE', "%" . request('name') . "%")->get();
    return response()->json($query);
}
Enter fullscreen mode Exit fullscreen mode

That’s it! By using this syntax, we can easily search and retrieve the desired tags from our database.

Now Lets define the new route:

Route::get('tags', [TagController::class, 'index'])->name('tags.search');
Enter fullscreen mode Exit fullscreen mode

As you see in following pic I just add some sample data and make a request:

Image description

Well, let’s see how we can use it in JavaScript. To make the HTTP request, we can use a popular package called Axios. The good news is that Axios is already installed by default in Laravel. However, if you don’t have it, you can easily install it using the following command:

npm install axios
Enter fullscreen mode Exit fullscreen mode

Once you have Axios set up, let’s go back to our JavaScript code and make the request:

let tagSearchInput = document.getElementById("search-tag");

tagSearchInput.addEventListener("input", async function (e) {
    const response = await axios.get(`/tags?name=${tagSearchInput.value}`);
    console.log(response.data);
});
Enter fullscreen mode Exit fullscreen mode

First, Let me explain what “async” and “await” mean in the code. In simple word, think of them as listeners that wait for the result of a request at a specific line.

Now, we need to display the list of results from our query. To achieve this, we can add an HTML element and populate the list with all the found items.

<script type="module">
    let tagSearchInput = document.getElementById("search-tag");
    let tagSearchList = document.getElementById("search-tag-list");

    tagSearchInput.addEventListener("input", async function (e) {
        const response = await axios.get(`/tags?name=${tagSearchInput.value}`);

        if (response.data.length) {
            tagSearchList.classList.remove("hidden");

            // remove all item inside list
            while (tagSearchList.firstChild) {
                tagSearchList.removeChild(tagSearchList.firstChild);
            }

            // add found tags inside the list
            response.data.forEach((tag) => {
                let li = document.createElement("li");
                li.appendChild(document.createTextNode(tag.name));
                li.className = "tag-list-item";
                // todo: uncomment after add tagSelected()
                // li.onclick = () => tagSelected(tag);
                tagSearchList.appendChild(li);
            });
        } else tagSearchList.classList.add("hidden");
    });
</script>
Enter fullscreen mode Exit fullscreen mode

The process involves iterating through the results. If there is any data, our item list is displayed, and we append a list of found tags to our search list.

Now, we need to implement click functionality for each tag item in the list. When a tag item is clicked, its tag id should be appended to a designated input field. This input field has a specific id, such as tags-select and its purpose is to store the selected tags within our form. We will utilize this input field when submitting the selected tags along with the task creation.

To achieve this, I have added three HTML elements as shown in the picture. The first element is an input field with the id tags-select, The second element is an unordered list (ul) which serves as the parent container for our search result tags that are listed. The third element is a div that visually represents the currently selected tags.

Image description

Next, when a user clicks on a tag item in the list, we want to perform the following actions:

  1. Create and append an element that represents the selected tag to the tags-select-list Additionally, we add a close button element, which we will later implement its functionality.
  2. Trigger another function that updates our tags-select input field by adding the id of the selected tag.
  3. Hide the dropdown list that displays the search results.
  4. Finally, clear the tag search input element to prepare it for adding another tag.

Here is the function that handles the click event for each listed tag:

const tagSelected = (tag) => {
    let span = document.createElement("span");
    span.id = tag.id;
    span.className = "tag-select-item";

    let closeBtn = document.createElement("i");
    // todo: uncomment after add removeSelectedTag()
    // closeBtn.onclick = () => removeSelectedTag(span);
    closeBtn.appendChild(document.createTextNode('×'));

    span.appendChild(closeBtn);
    span.appendChild(document.createTextNode(tag.name));

    tagSelectWrap.appendChild(span);
    tagSearchList.classList.add("hidden");

    tagSearchInput.value = "";
    tagSelectInput.value = inputTag(parseInt(tag.id));
}
Enter fullscreen mode Exit fullscreen mode

And this is the function that updates our tags-select input field:

function inputTag(tagId) {
    let tags = tagSelectInput.value.length
        ? JSON.parse(tagSelectInput.value)
        : [];
    if (tags.includes(tagId)) {
        const indexToRemove = tags.indexOf(tagId);
        tags.splice(indexToRemove, 1);
        tags = JSON.stringify(tags);
    } else {
        tags.push(tagId);
        tags = JSON.stringify(tags);
    }
    return tags;
}
Enter fullscreen mode Exit fullscreen mode

Here we have to convert array of selected tags id to string since our input accept string value.

Now, let’s discuss the functionality for removing selected tags. When a user clicks on the close button, the corresponding element needs to be removed from both the tags-select-list and our input field. Here is the code for the removal functionality:

const removeSelectedTag = (el) => {
    tagSelectInput.value = inputTag(parseInt(el.id));
    tagSelectWrap.removeChild(el);
}
Enter fullscreen mode Exit fullscreen mode

Amazing let see our fantastic result:

Image description

The final step is to implement the functionality for adding a new tag when the user presses Enter and the entered tag is not found in our existing records. In this case, we will send the entered tag to the server and store it as a new tag. As a result, we will repeat the entire process of clicking on a list item for the newly added tag.

To add a new tag, you simply need to include a store function inside the tag controller. Here is how the store function can be implemented:

public function store(StoreTagRequest $request)
{
    $tag = Tag::create($request->validated());
    return response()->json($tag);
}
Enter fullscreen mode Exit fullscreen mode

To ensure that everything is set up correctly, please make sure to add the appropriate route and a simple StoreTagRequest class. We have covered this step earlier, and it is relatively straightforward.

Now let’s return to our search select input and handle the Enter key events. First, we need to check if there are no tags in our records. If there are no existing tags, we will proceed to store a new tag using the following steps:

tagSearchInput.addEventListener("keydown", async function (e) {
    if (e.key === "Enter") {
        e.preventDefault();

        const response = await axios.get(
            `/tags?name=${tagSearchInput.value}`
        );

        if (!response.data.length) {
            const { data } = await axios.post("/tags", {
               name: tagSearchInput.value
            });
            tagSelected(data);
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

Now, we have the exciting capability of adding new tags within our app! Isn’t that amazing?

Well, Let’s return to the task creation process. Here, we simply need to retrieve the entered tags from the input fields when the request is sent.

let’s proceed with updating our askStoreRequest to include selected tags:

public function rules(): array
{
    return [
        'title' => 'required|min:3|max:120',
        'description' => 'nullable|min:3|max:255',
        'expired_at' => 'nullable|date|after:now'
    ];
}


public function validated($key = null, $default = null)
{
    if ($key == 'tags') return json_decode(request('tags'));

    return $this->validator->validated();
}
Enter fullscreen mode Exit fullscreen mode

I have just refactored the validated method to convert a string of ids into an array of IDs (We cannot merge because merging could lead to conflicts, as there is no column named “tags” inside the tasks model).

Now, let's test its functionality by using two given tags.

Image description

That’s it!
Store a Task with given tags

Now how to relates this tags to our tasks?

As I mentioned before, we need a way to manage the relationship between tags and tasks. In a previous article, we discussed this topic you can find out here:

To define the relationship between tags and tasks, we’ll need another table that will record all the connections between these models. This middle table will have two main attributes, and those are the id of each model.

So, let’s get started and define the table using a migration:

php artisan make:migration create_tag_task_table
Enter fullscreen mode Exit fullscreen mode

And this is our migration setup:

Schema::create('tag_task', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->foreignId('task_id')->constrained()->cascadeOnDelete();
});
Enter fullscreen mode Exit fullscreen mode

Right now we need to define relation function for both model that is is incredibly useful and includes many helpful functions that make working with relations in Laravel models straightforward. For both models, we need to declare that each model belongs to the other one.

class Task extends Model
{
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

    // ...
}


class Tag extends Model
{
    public function tasks()
    {
        return $this->belongsToMany(Task::class);
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Let’s back to our controller where we need to define relation between created task and selected tags, Here we will use another function call attach just like bellow:

public function store(StoreTaskRequest $request)
{
    $task = auth()->user()->tasks()->create($request->validated());

    $task->tags()->attach($request->validated('tags'));

    return redirect("/tasks", 201);
}
Enter fullscreen mode Exit fullscreen mode

As you notice after selecting some tags and post store request to server you will notice that a created task is associated with the chosen tags.

Update a task’s tags

Now what’s about updating tags for a given task,

The main requirement is to enable the select input using the provided tags. Fortunately, in the Laravel blades, there is a useful feature called “props” that allows for the insertion of variables within components. In our project, we need to define a tags variable, with a default value of null. This will allow us to utilize the component effectively:

@props([ 'tags' => null ])
Enter fullscreen mode Exit fullscreen mode

Then we need to add selected tags to tags-select input:

<input 
  type="text" 
  name="tags"
  id="tags-select" 
  value="{{ isset($tags) ? $tags->pluck('id') : ''}}"
  hidden
>
Enter fullscreen mode Exit fullscreen mode

And show selected tags inside our tags-select-list :

<div class="flex flex-wrap space-x-1" id="tags-select-list">
    @forelse($tags ?? [] as $tag)
        <span id="{{ $tag->id }}" class="tag-select-item">
          <i>×</i>{{ $tag->name }}
        </span>
    @empty
    @endforelse
</div>
Enter fullscreen mode Exit fullscreen mode

As you can see, the forelse is used in cases where there may be an empty list. You can also use a simple foreach loop along with an if statement, but the forelse provides a simpler alternative.

Now, you can use this component into our edit view. When you do so, you will notice that all the selected tags are displayed correctly. However, there is one small thing that still needs to be addressed: the closing functionality for these tags.

In this situation, all we need to do is search within our list. If any span elements are found, we can assign the close handler function to the close button. This process is straightforward, and you can refer to the following code to implement it:

// Get all the tags inside the parent element add close event
[...tagSelectWrap.getElementsByTagName('span')].forEach(spanTag => {
    spanTag.getElementsByTagName('i')[0].onclick = () => removeSelectedTag(spanTag);
})
Enter fullscreen mode Exit fullscreen mode

That’s it!

Then I add our search components into the task edit view, and refactor our update method as bellow:

Image description

Now if we add an additional task and make updates, you will notice that not only will new tags be added, but the last attached tags will also be attached again.

Image description

One possible solution is to first detach all previously attached tags and then attach them all together. However, another option is to use a helper function called sync which handles the entire process in one step:

// option 1:
$tags_id = $task->tags()->pluck('id');
$task->tags()->detach($tags_id);
$task->tags()->attach($request->validated('tags'));

// option 2:
$task->tags()->sync($request->validated('tags'));
Enter fullscreen mode Exit fullscreen mode

That’s it Let’s see the result:

Image description

We’ve just completed an incredible and extensive article that covers a wide range of topics, including the fascinating realm of many-to-many relationships. In addition, we introduced a brand new model called “tag” and and establish a relationship between user tasks and the associated tags. To enhance the user experience, we even developed blade components specifically designed to manage tags within our forms.

And again this is the repo of our project. Throughout this series of materials, we’ll be taking gradual steps to transform it into a fully functional application.

Happy coding!

Top comments (2)

Collapse
 
codefarmbs profile image
Code Farm

Thank u man that was awesome! I like your minimal design

Collapse
 
kazemmdev profile image
Kazem

Thanks