Article

I am fairly active on the CakePHP IRC channel as dakota, and one of the questions that I commonly see asked is how to implement dynamically adding form fields. Particular as a grid, or table of fields. Most of the tutorials or guides out there are either not very good, or out of date. This aims to, hopefully, solve that problem.

This tutorial is written for CakePHP 2.x, and assumes that you have some CakePHP and jQuery knowledge. You should be able to fairly easily migrate the code presented here over to CakePHP 3.0 if needed.

We will be creating a simple form to capture a student's results. We have two models, Student and Grade. Student hasMany Grade, and the database schema is created with:

--
-- Table structure for table `grades`
--

CREATE TABLE IF NOT EXISTS `grades` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `student_id` int(11) NOT NULL,
  `subject` varchar(200) COLLATE utf8_unicode_ci NOT NULL,
  `grade` varchar(2) COLLATE utf8_unicode_ci NOT NULL,
  `created` datetime DEFAULT NULL,
  `modified` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `student_id` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;

-- --------------------------------------------------------

--
-- Table structure for table `students`
--

CREATE TABLE IF NOT EXISTS `students` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(300) COLLATE utf8_unicode_ci NOT NULL,
  `created` datetime DEFAULT NULL,
  `modified` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;

The initial files are then baked using Cake Bake which gives us a view that looks like:

<div class="students form">
<?php echo $this->Form->create('Student'); ?>
    <fieldset>
        <legend><?php echo __('Add Student'); ?></legend>
    <?php
        echo $this->Form->input('name');
    ?>
    </fieldset>
<?php echo $this->Form->end(__('Submit')); ?>
</div>
<div class="actions">
    <h3><?php echo __('Actions'); ?></h3>
    <ul>

        <li><?php echo $this->Html->link(__('List Students'), array('action' => 'index')); ?></li>
        <li><?php echo $this->Html->link(__('List Grades'), array('controller' => 'grades', 'action' => 'index')); ?> </li>
        <li><?php echo $this->Html->link(__('New Grade'), array('controller' => 'grades', 'action' => 'add')); ?> </li>
    </ul>
</div>

What we want to do from here, is create the ability to add, or remove multiple grades/subjects from the student when we create the student entry. First, we create a new fieldset by inserting the following after the </fieldset>.

<fieldset>
    <legend><?php echo __('Grades');?></legend>
    <table id="grade-table">
        <thead>
            <tr>
                <th>Subject</th>
                <th>Grade achieved</th>
                <th>&nbsp;</th>
            </tr>
        </thead>
        <tbody></tbody>
        <tfoot>
            <tr>
                <td colspan="2"></td>
                <td class="actions">
                    <a href="#" class="add">Add grade</a>
                </td>
            </tr>
        </tfoot>
    </table>
</fieldset>

Here we've created a table with an id of grade-table, inside the table we're making use of the <thead>, <tbody>, and <tfoot> tags to make it easier to append rows later on. The <thead> defines the heading of the table, <tfoot> defines the footer (In this case with an Add grade button). <tbody> is where we will be inserting our dynamic fields. You'll notice that at this stage, no fields have actually been defined. This is because we will be creating an element with the fields, and then we will make use of a lovely javascript templating engine to actually insert each row into the <tbody> of our table.

Create View/Elements/grades.ctp file with the following contents.

<?php
$key = isset($key) ? $key : '<%= key %>';
?>
<tr>
    <td>
        <?php echo $this->Form->hidden("Grade.{$key}.id") ?>
        <?php echo $this->Form->text("Grade.{$key}.subject"); ?>
    </td>   
    <td>
        <?php echo $this->Form->select("Grade.{$key}.grade", array(
            'A+',
            'A',
            'B+',
            'B',
            'C+',
            'C',
            'D',
            'E',
            'F'
        ), array(
            'empty' => '-- Select grade --'
        )); ?>
    </td>
    <td class="actions">
        <a href="#" class="remove">Remove grade</a>
    </td>
</tr>

Here we have a simple table row with three fields (id, subject and grade) and a button. The id field is so that we can edit grades at a later stage. On the 2nd line, we define a $key variable with a default value of <%= key %>, this is a special tag used by the underscore.js templating engine. If you wish to use another templating engine you may, but I normally use underscore.js for these type of things because I normally have underscore.js included in my applications due to the other utility functions it provides.

Now that we have defined the element, we can include it into our view file. Add the following after the </fieldset> that was created earlier.

<script id="grade-template" type="text/x-underscore-template">
    <?php echo $this->element('grades');?>
</script>

We define a script tag which is an underscore template (text/x-underscore-template) with an id of grade-template and we include our element into this script. Next up is our javascript.

First, open View/Layout/default.ctp and add the following just under the echo $this->Html->css('cake.generic'); line.

echo $this->Html->script(array(
    '//cdnjs.cloudflare.com/ajax/libs/jquery/1.11.2/jquery.min.js',
    '//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js'
));

We are loading the latest (at time of writing this) version of jquery and underscore from a CDN. Both are useful enough to include across your entire application, so we added them to the layout file.

Back to our add.ctp file, add the following at the end (Ideally all JS code should be in separate files, but for demo purposes this is easier),

<script>
$(document).ready(function() {
    var
        gradeTable = $('#grade-table'),
        gradeBody = gradeTable.find('tbody'),
        gradeTemplate = _.template($('#grade-template').remove().text()),
        numberRows = gradeTable.find('tbody > tr').length;

    gradeTable
        .on('click', 'a.add', function(e) {
            e.preventDefault();

            $(gradeTemplate({key: numberRows++}))
                .hide()
                .appendTo(gradeBody)
                .fadeIn('fast');
        })
        .on('click', 'a.remove', function(e) {
                e.preventDefault();

            $(this)
                .closest('tr')
                .fadeOut('fast', function() {
                    $(this).remove();
                });
        });

        if (numberRows === 0) {
            gradeTable.find('a.add').click();
        }
});
</script>

This is what does all the magic. First, we create an event handler to listen for the document ready event. This is because you cannot safely manipulate an HTML page until the browser signals that it has been fully loaded. Next, we create some variables, we cache the jQuery references to our #grade-table and the <tbody>. It is always best practise with jQuery to call $() as little as possible. Next, we create a gradeTemplate variable using the underscorejs template engine (_.template()). This creates a javascript function that will render our template. We the contents of our previously created #grade-template script, and remove the template from the document (Because we no longer need it). We also read the initial number of rows that exist.

Next, we create two delegated event handlers on our #grade-table. A delegated event allows us to listen to events that occur inside the element on children that may not exist at the time we created the event listener. If the user clicks on the Add grade button, we render our underscorejs template, append it to the bottom of our tbody and fade it in. If the user clicks on a Remove grade button, we find the parent row for the button, fade it out and then remove it from the document. The last three lines ensure that there will be at least one grade when the page is loaded.

At this stage, your view should look something like,

However, your grades will not be saved yet! For that we need to go and modify our controller. Open up Controller/StudentsController.php and find the add action. In the add action, you'll find a line that looks like if ($this->Student->save($this->request->data)) {, change this to if ($this->Student->saveAssociated($this->request->data)) {. Your grades will now be saved.

The resulting data array will look something like ($this->request->data):

array(
    'Student' => array(
        'name' => 'Student Name'
    ),
    'Grade' => array(
        0 => array(
            'id' => null,
            'subject' => 'English',
            'grade' => 'A'
        ),
        1 => array(
            'id' => null,
            'subject' => 'Mathematics',
            'grade' => 'C'
        ),
        2 => array(
            'id' => null,
            'subject' => 'Science',
            'grade' => 'B+'
        )           
    )
)

If you try to save the student with validation errors, you'll notice that your grades will disappear! This is obviously not what we want. To fix this, we need to ensure that any previously submitted grades are rendered along with our view. Open the View/Students/view.ctp file, and change the tbody to look like

<tbody>
    <?php if (!empty($this->request->data['Grade'])) :?>
        <?php for ($key = 0; $key < count($this->request->data['Grade']); $key++) :?>
            <?php echo $this->element('grades', array('key' => $key));?>
        <?php endfor;?>
    <?php endif;?>
</tbody>

And there we go. The next steps (Which I'll leave up to you to figure out), is to allow editing of a student and their grades, and to handle deleting of a grade from the database.

The code in this tutorial can very easily be migrated over to CakePHP 3 if you wish, the primary difference is that the field names will be grades.{$key}.id, you don't need to make the controller change, and instead of looping on $this->request->data['Grade'], you'll loop on $subject->grades.

If you have any questions, please do not hesitate to ask! I've create a github repository that has all the code used in this tutorial.

Comments