IncludeBeer

How to build a basic web application with CodeIgniter 4 (part 5)

Published on 2 January 2021
Article's image
Image credit: Bram Naus

In the fourth part we have improved the presentation of the recipe list with pagination. Now we are going to improve it a bit more by adding a search form. The form contains two text fields: a field to do a text search and a field to specify the number of recipes to display per page.

Search form

To create the search form, we could write the HTML code directly, but we will take advantage of the set of functions for handling forms (form helper). The form_open() and form_close() functions are used to create the HTML tags <form> and </form>. The first parameter is the URI to submit the form to. The second parameter is for the HTML attributes of the form. We will therefore open the form with this line:

<?= form_open('/', ['class' => 'form-inline']) ?>

The framework takes care of generating the full URL, which will transform "/" into "http://ci4.test:8888/". Then it adds the attributes that it was asked to add, in this example it will add an attribute for the Bootstrap form-inline class. Since it was not specified in the attribute list, the default method is post and the value of accept-charset is the value of the $charset parameter of the application configuration file app/Config/App.php. This will generate the following line:

<form action="http://ci4.test:8888/" class="form-inline" method="post" accept-charset="utf-8">

To create text fields, we use the form_input() function. The first parameter is the field name. The second parameter is its value. Here we use the ?? operator as a precaution to pass it an empty string in case $search['text'] is not defined. Just like for form_open(), the next parameter is an array of HTML attributes to add to the <input> field. We therefore add Bootstrap classes to have a slightly more interesting visual and a placeholder which will display the word "Text" in the text zone. For the second field, we also add an id and a CSS style:

<?= form_input('search_text',
               $search['text'] ?? '',
               ['class' => 'form-control my-1 mr-3', 'placeholder' => "Text"]) ?>

<?= form_input('search_nb_per_page',
               $search['nb_per_page'] ?? '',
               ['id' => 'search_nb_per_page', 'class' => 'form-control my-1 mr-3', 'style' => 'width:70px']) ?>

This will generate the following lines:

<input type="text" name="search_text" value="" class="form-control my-1 mr-3" placeholder="Text">
<input type="text" name="search_nb_per_page" value="" id="search_nb_per_page" class="form-control my-1 mr-3" style="width:70px">

If you prefer, you can simply pass an array with all the attributes in the first parameter of the form_input() function instead of using the first two parameters for the name and value:

<?= form_input([
        'name' => 'search_text',
        'value' => $search['text'] ?? '',
        'class' => 'form-control my-1 mr-3',
        'placeholder' => "Text"
    ]) ?>

For the <label> we use the form_label() function. The first parameter is the text to display, the second is the id that this label refers to and the last is an array of additional HTML attributes:

<?= form_label("Number per page", 'search_nb_per_page', ['class' => 'my-1 mr-2']) ?>

This will generate the following line:

<label for="search_nb_per_page" class="my-1 mr-2">Number per page</label>

For the button we use the form_submit() function.

<?= form_submit('search_submit',
                "Search",
                ['class' => 'btn btn-outline-primary my-1']) ?>

This will generate the following line:

<input type="submit" name="search_submit" value="Search" class="btn btn-outline-primary my-1">

Here is the full view with the new search form:

app/Views/recipe_list.php

<?php
/**
 * @var string $page_title                       The page title (automatically created by CI from the $data array)
 * @var string $page_subtitle                    The page subtitle (automatically created by CI from the $data array)
 * @var array $search                            Search criteria
 * @var array $recipes                           List of recipes (automatically created by CI from the $data array)
 * @var App\Entities\Recipe $recipe              One recipe (created by the foreach instruction)
 * @var \CodeIgniter\Pager\PagerRenderer $pager  Pagination class instance
 */
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title><?= esc($page_title) ?></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
      integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
      crossorigin="anonymous">

<style type="text/css">
.title
{
    padding: 3rem 1.5rem;
}

article
{
    padding: 1.5rem 1.5rem;
}
</style>

</head>

<body>

<main role="main" class="container">
    <div class="title">
        <h1>
            <?= esc($page_title) ?>
            <small class="text-muted"><?= esc($page_subtitle) ?></small>
        </h1>
    </div>

    <div class="container">

        <h3>List of recipe</h3>
        <div class="my-3">
            <?= form_open('/', ['class' => 'form-inline']) ?>
                <?= form_input('search_text',
                               $search['text'] ?? '',
                               ['class' => 'form-control my-1 mr-3', 'placeholder' => "Text"]) ?>

                <?= form_label("Number per page", 'search_nb_per_page', ['class' => 'my-1 mr-2']) ?>

                <?= form_input('search_nb_per_page',
                               $search['nb_per_page'] ?? '',
                               ['id' => 'search_nb_per_page', 'class' => 'form-control my-1 mr-3', 'style' => 'width:70px']) ?>

                <?= form_submit('search_submit',
                                "Search",
                                ['class' => 'btn btn-outline-primary my-1']) ?>
            <?= form_close() ?>
        </div>

        <ul>
<?php foreach ($recipes as $recipe): ?>
            <li><?= anchor('recipe/' . $recipe->slug, $recipe->title) ?></li>
<?php endforeach; ?>
        </ul>

        <?= $pager->links('default', 'bootstrap') ?>

    </div>

</main>

<footer>
    <p class="text-center">&copy; 2020 My recipe website</p>
</footer>

</body>
</html>

The get/post route

The search form is submitted to the '/' route with the post method. We must therefore modify the route configuration file, otherwise we will end up with a 404 error. We could add the route post as follows:

$routes->get('/', 'RecipesController::index');
$routes->post('/', 'RecipesController::index');

But the easiest is to use the match() function to associate the same route with the post and get methods in a single command. We will therefore replace the route to '/' by this line:

$routes->match(['get', 'post'], '/', 'RecipesController::index');

Here is the new list of routes, now including the route to submit the search form:

app/Config/Routes.php

$routes->addPlaceholder('slug', '[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*');
$routes->match(['get', 'post'], '/', 'RecipesController::index');
$routes->get('recipe/(:num)', 'RecipesController::recipeById/$1');
$routes->get('recipe/(:slug)', 'RecipesController::recipeBySlug/$1');

The controller: RecipesController

Now that we can submit a form, we have to read the submitted values. The '/' route is directed to the index() function of the RecipesController for both the get and post methods. This function is responsible for displaying the list and it is also to this function that the form is sent to filter the list. So this is where we get the form values. The $this->request->getMethod() function tells us what type of request it is. If it's a post request, we get the form values with $this->request->getPost(). All you have to do is pass the field name as a parameter. All search criteria are saved in the $search variable of type array:

if ($this->request->getMethod() === 'post')
{
    $search = [
        'text' => $this->request->getPost('search_text'),
        'nb_per_page' => $this->request->getPost('search_nb_per_page'),
    ];
}

We pass the variable $search as a parameter to the getListRecipes($search) function so that the search and the pagination take into account our search criteria. It is also passed to the view so that the form is automatically filled with the values of the last search performed.

At the moment, search criteria are only obtained when submitting a new search. If we go to the next page after doing a search, it will display the complete list because the form is not submitted when we click on the pagination links. We will therefore save the criteria in the session. To save an item, call session()->set() with the name and value of the item. To retrieve this value, call session() with the name of the item:

// Save the search criteria to the session data
session()->set('search_recipe', $search);

// Get the search criteria from the session data
$search = session('search_recipe');

So, after checking if a search form has been submitted, we will check if a search has been previously saved in the session. If this is the case, we will get it, otherwise we define an empty criterion. Finally, we do a litlle bit of validation for the number of pages. If we receive 0, a negative number or no value, we take the default value from the configuration. The number is limited to a maximum of 100 items per page to prevent a user from requesting the entire database by entering a huge number. Last thing, we must load the form helper functions with helper('form') so that they are available when we load the view. This will load the file system/Helpers/form_helper.php. Here is the controller in its entirety with all these changes:

app/Controllers/RecipesController.php

<?php namespace App\Controllers;

use App\Libraries\MyRecipes;

class RecipesController extends BaseController
{
    /**
     * List of recipes
     * @return string
     */
    public function index()
    {
        // If a form was submitted
        if ($this->request->getMethod() === 'post')
        {
            // Get the form's search criteria
            $search = [
                'text' => $this->request->getPost('search_text'),
                'nb_per_page' => $this->request->getPost('search_nb_per_page'),
            ];
        }
        // Else, if search criteria have been saved to the session data
        else if (session('search_recipe') !== null)
        {
            // Get the search criteria from the session data
            $search = session('search_recipe');
        }
        else
        {
            // Default search criteria
            $search = [
                'text' => null,
                'nb_per_page' => null,
            ];
        }

        if ($search['nb_per_page'] !== null)
        {
            // Convert the value to 'int' (integer)
            $search['nb_per_page'] = (int)$search['nb_per_page'];

            // If negative or 0, set to null to get the default value from the Pager's configuration
            if ($search['nb_per_page'] <= 0)
            {
                $search['nb_per_page'] = null;
            }

            // No more than 100 recipes per page
            if ($search['nb_per_page'] > 100)
            {
                $search['nb_per_page'] = 100;
            }
        }

        // Save the search criteria to the session data
        session()->set('search_recipe', $search);

        // Create an instance of our library
        $myRecipes = new MyRecipes();

        // Collect all the data used by the view in a $data array
        $data = [
            'page_title' => "My Recipes",
            'page_subtitle' => "I present you my favorite recipes...",
            'recipes' => $myRecipes->getListRecipes($search),
            // Pass the search criteria to the view
            'search' => $search,
            // Pass the paginnation class instance to the view
            'pager' => $myRecipes->recipeModel->pager,
        ];

        // Load the form helper
        helper('form');

        /* Each of the items in the $data array will be accessible
         * in the view by variables with the same name as the key:
         * $page_title, $page_subtitle, $recipes, $search and $pager
         */
        return view('recipe_list', $data);
    }

    /**
     * One recipe
     * @param int $id
     * @return string
     */
    public function recipeById (int $id)
    {
        // Create an instance of our library
        $myRecipes = new MyRecipes();

        $data = [];

        /* Get the recipe for the id received in parameter.
         * If the recipe does not exist, throw a page not found exception (error 404)
         */
        if ( ! $data['recipe'] = $myRecipes->getRecipeById($id))
        {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('recipe', $data);
    }

    /**
     * One recipe
     * @param string $slug
     * @return string
     */
    public function recipeBySlug (string $slug)
    {
        // Create an instance of our library
        $myRecipes = new MyRecipes();

        $data = [];

        /* Get the recipe for the slug received in parameter.
         * If the recipe does not exist, throw a page not found exception (error 404)
         */
        if ( ! $data['recipe'] = $myRecipes->getRecipeBySlug($slug))
        {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('recipe', $data);
    }
}

The MyRecipes library

The only thing left to do is to modify the MyRecipes library to use the search criteria when getting data. We receive the search criteria via a new parameter: getListRecipes (array $search). If we do a text search, we add WHERE LIKE clauses to search the title and the instructions with the like() and orLike() functions:

if ( ! empty($search['text']))
{
    $this->recipeModel
        ->like('title', $search['text'])
        ->orLike('instructions', $search['text']);
}

For the number of pages, we pass the value to the paginate() function. If there is no value, we pass null, which will take the default value from the configuration:

$nb_per_page = ! empty($search['nb_per_page']) ? $search['nb_per_page'] : null;

$recipes = $this->recipeModel
    ->orderBy('id')
    ->paginate($nb_per_page);

For example, if we search for the text "bake at 350" with 10 recipes per page, the query generated would be as follows:

  SELECT `id`, `slug`, `title`
    FROM `recipe`
   WHERE `title` LIKE '%bake at 350%' ESCAPE '!'
      OR `instructions` LIKE '%bake at 350%' ESCAPE '!'
ORDER BY `id`
   LIMIT 10

This can be validated using the debug toolbar, in the Database tab. Take note that this bar is only available when the application environment is different from production. See this article to know how to define the environment.

Here is the entire MyRecipes library with these few changes:

app/Libraries/MyRecipes.php

<?php namespace App\Libraries;

use App\Models\RecipeModel;
use App\Models\IngredientModel;

class MyRecipes
{
    public $recipeModel;
    public $ingredientModel;

    public function __construct()
    {
        $this->recipeModel = new RecipeModel();
        $this->ingredientModel = new IngredientModel();
    }

    /**
     * Get the list of recipes
     * @param array $search
     * @return array
     */
    public function getListRecipes (array $search)
    {
        // Only get id, slug and title fields
        $this->recipeModel->select('id, slug, title');

        // If we do a text search, look in the title and instructions
        if ( ! empty($search['text']))
        {
            $this->recipeModel
                ->like('title', $search['text'])
                ->orLike('instructions', $search['text']);
        }

        // If we don't ask for a specific number of recipe per page, get the default value
        $nb_per_page = ! empty($search['nb_per_page']) ? $search['nb_per_page'] : null;

        // Add the sort order and pagination, then return the results
        $recipes = $this->recipeModel
            ->orderBy('id')
            ->paginate($nb_per_page);

        return $recipes;
    }

    /**
     * Get a recipe by its id
     * @param int $id
     * @return object|NULL
     */
    public function getRecipeById (int $id)
    {
        // Get the recipe by its id
        $recipe = $this->recipeModel->find($id);

        if ($recipe !== null)
        {
            $recipe->ingredients = $this->ingredientModel
                ->where( ['recipe_id' => $recipe->id] )
                ->orderBy('id')
                ->findAll();
        }

        return $recipe;
    }

    /**
     * Get a recipe by its slug
     * @param string $slug
     * @return object|NULL
     */
    public function getRecipeBySlug (string $slug)
    {
        // Get the recipe by its slug
        $recipe = $this->recipeModel->where('slug', $slug)->first();

        if ($recipe !== null)
        {
            $recipe->ingredients = $this->ingredientModel
                ->where( ['recipe_id' => $recipe->id] )
                ->orderBy('id')
                ->findAll();
        }

        return $recipe;
    }
}

Fifth part finished!

You can now refresh the page http://ci4.test:8888 which will now display a search form. The form's values are saved in the session data which allows the form to "remember" what was searched for.