Hierarchical data with PHP and jQuery

hierarchical-form.png

Demo here (go ahead and submit the form as much as you like, it will keep pre-populating with the data you submit).

Given input names like for instance array[1][subarray][0][image_name] it’s possible to treat $_POST[’array’] as a raw array in PHP. The important thing to keep in mind is to not mix up the numerical keys, in the above case 1 and 0. This is a recurring theme in this tutorial.

Below we have an example of PHP generating HTML in the way just described. The result is a form containing a PHP array two levels deep.

<script src="/phive/js/nested_form.js" type="text/javascript" charset="utf-8"></script>
	<table id="form-table">
	<tbody>
		<tr>
			<td>Game category name:</td>
			<td>Game category image (ex: the image.jpg part in en_image.jpg):</td>
			<td>Games</td>
		</tr>
		<?php $i = 0; foreach($this->game_cats as $cat): ?>
			<tr>
				<td>
					<input type="text" name="game_cats[<?php echo $i?>][name]" value="<?php echo $cat['name'] ?>"/>
				</td>
				<td>
					<input type="text" name="game_cats[<?php echo $i?>][image]" value="<?php echo $cat['image'] ?>"/>
				</td>
				<td>
					<table>
					<tbody>
						<tr>
							<td>Game name:</td>
							<td>Game image (works the same as category image):</td>
						</tr>
						<?php
							if(empty($cat['games']))
								$cat['games'] = array('name' => '', 'image' => '');
						 	$j = 0; 
						 	foreach($cat['games'] as $game): ?>
							<tr>
								<td>
									<input type="text" name="game_cats[<?php echo $i?>][games][<?php echo $j ?>][name]" value="<?php echo $game['name'] ?>"/>
								</td>
								<td>
									<input type="text" name="game_cats[<?php echo $i?>][games][<?php echo $j ?>][image]" value="<?php echo $game['image'] ?>"/>
								</td>
							</tr>
						<?php $j++; endforeach ?>
					</tbody>
					</table>
				</td>
			</tr>
		<?php $i++; endforeach; ?>
	</tbody>
	</table>

Note the empty($cat[’games’]) check above, it’s there to check if a node is empty and in such case generate empty input fields for that node.

Given the above HTML, what would the necessary jQuery / JavaScript look like in order to be able to do the following:

1.) Automatically create Add Item buttons on each node.

2.) Have the Add Item buttons create a new node at the level in question and at the same time create empty “template nodes” with one row each for each and every child node.

3.) Whilst accomplishing #2 the code also needs to keep track of the PHP array numbers in order to not mix things up.

Here is my stab at a solution (which currently works for the above array, not tested on more deeply nested data):

function trHtml(el){
	return "<tr>"+el.html()+"</tr>";
}

function setupBtn(ltr, lvl){
	ltr.next().find("button").unbind('click').click(function(){
		addNestedItem(ltr, lvl);
	});
}

function fixNestedInputName(input, lvl, start_lvl){
	var arr = input.attr("name").match(/\w+/g);
	var name_start = arr.shift();
	var new_name = name_start + "[";
	var num_counter = 0;
	for(i = 0; i < arr.length; i++){
		var brstr = i == arr.length - 1 ? "]" : "][";
		if(/^\d+$/.test(arr[i])){
			
			if(start_lvl == lvl){
				if(num_counter == lvl)
					new_name += (parseInt(arr[i]) + 1) + brstr;
				else
					new_name += arr[i] + brstr;	
			}else{
				if(num_counter < lvl)
					new_name += (parseInt(arr[i]) + 1) + brstr;
				else
					new_name += '0' + brstr;
			}
			
			num_counter++;
		}else
			new_name += arr[i] + brstr;
	}
	input.attr("name", new_name).val("");
}

function setupNewestBtn(table, lvl){
	table.find("button").last().unbind('click').click(function(){
		addNestedItem(jQuery(this).parent().prev(), lvl);
	});
}

function fixNewNestedItem(ltr, lvl, start_lvl){
	ltr.parent().children().last().prev().children().each(function(){
		var table = jQuery(this).children("table").find("tbody:first");
		var input = jQuery(this).children("input");
		if (table.size() > 0) {
			var tr = table.children().last().prev();
			fixNewNestedItem(tr, lvl + 1, start_lvl);
			
			table.html(trHtml(table.children().first()) +
			trHtml(tr) +
			trHtml(table.children().last()));
			
			setupNewestBtn(table, lvl + 1);
			
			table.find("input").val("");
			
		}else if (input.size() > 0)
			fixNestedInputName(input, lvl, start_lvl);
		
		setupNewestBtn(ltr.parent(), lvl);
	});
}

function addNestedItem(ltr, lvl){
	ltr.after("<tr>"+ltr.html()+"</tr>");
	fixNewNestedItem(ltr, lvl, lvl);
}

function setupNestedTable(nt, lvl){
	if(typeof nt == 'string')
		var nt = jQuery("#"+nt+" > tbody");
		
	var ftr 	= nt.children().first();
	var ltr 	= nt.children().last();
	
	var tdcount = ftr.children().size();
	nt.append('<tr colspan="'+tdcount+'"><button onclick="return false;">Add Item</button></tr>');
	
	setupBtn(ltr, lvl);
	
	nt.children().each(function(){
		var table = jQuery(this).find("table:first");
		if (table.size() > 0) {
			setupNestedTable(table.find("tbody:first"), lvl + 1);
		}
	});
	
}

jQuery(document).ready(function(){
	setupNestedTable("form-table", 0);
});

This is a major listing, let’s go through it i order of execution, first up is setupNestedTable. We start with actually getting the table body containing all the form elements first. Then we identify the first row (ftr) and the last row (ltr). Next we check how many columns the first row contains (one for each headline). This is a cosmetic consideration, we need to know the column count when we create a new row below the last node (in every branch) so we can set the colspan attribute. We use setupBtn to setup the click callback on each button and finally we run setupNestedTable recursively on each child.

Over to setupBtn. We first unbind the click callback to avoid reassigning it repeatedly, this is possible on the topmost level due to the fact that we will repeatedly copy the button and assign new callbacks to it on that level.

SetupBtn calls addNestedItem which is basically what will happen when the button gets clicked, we add a new branch at the current level by way of copying the prior item/row at the current level (ltr).

After the new branch has been created comes the hard part of first removing superfluous items from the copied node (remember we only need one new item on each level) and resetting their values. We also need to fix the names of the new nodes so that that various numerical keys have increased properly. This whole process is started in fixNewNestedItem. The convoluted start of this function can be explained by the fact that I was not able to simply do ltr.next() to get at the next (just inserted) row, probably because the new row was inserted through putting new HTML in the parent table. In other words, jQuery can’t find it unless we take one step up and then start looking because our copied row is not able to “see” his new sibling.

Anyway, we start looping through the columns (tds) of our newly copied row and we do either of the following:

1.) If a column contains a table it means we have a new node and we call fixNewNestedItem in a recursive fashion. After that we make sure only the first (the headlines), second last (the new “template” item) and the last (the button) rows are still left, everything else is removed. Next we reset the potentially new button. Finally we set the values of all inputs at the current level to an empty string: “”.

2.) If we don’t have a child table it means we’re dealing with a simple data item, not a child node/tree. In that case we need to fix its new name. Since it’s a copy it will retain the name of the copied input and we can’t have that, we need to increment all numerical keys, and we do that in fixNestedInputName.

In the following section pretend we copy a root node with an item name of arr[0][name] which has two children and the last item is called arr[0][children][1][name]. The goal is to get a new name for the parent node item of arr[1][name] and the child of the root copy should be arr[1][children][0][name].

We begin with extracting all the x’s in arr[x][x], we also get the arr part and store it in name_start. Next we start looping through our array of x’s. If we have a numerical in the x we check if we are at the same level we started at (start_lvl == lvl). If we are at the same level (arr[0][name]) we check which number we are currently working with, in this case 0 which is also the level we are currently at, hence we increase the numer, we get arr[1][name]. That’s it for the root level, we don’t have any more numerical keys here. The logic will however through recursion in fixNewNestedItem get us to the children.

We are now at level 1 and we have found the first numerical: arr[0][children][1][name]. The start level will always be 0 so we don’t match that check and move over to the else part where we check if the num_counter (currently at 0) is lower than the current level (1 remember?). We have a match and we increase the number, getting arr[1][children][1][name] for this part. Next time we run into a numerical arr[1][children][1][name] the num_counter < lvl check will not be true anymore as both lvl and num_counter are now 1. Hence we set the number to 0 for a final result of arr[1][children][0][name].

Finally we remove all prior values in the current input.

As you’ve probably realized by now, the current JS code won’t handle removing items and nodes. To be honest I grew too lazy and decided to implement that in PHP when we have the data on the server. The PHP logic will simply filter all empty items and all empty nodes from the input. That’s also why we needed to provide empty template nodes and items in the HTML above, it’s totally possible that any data to be edited can completely lack nodes and values after the below PHP is finished with them:

function isEmpty($tree){
	$empty = true;
	foreach($tree as $val){
		$empty = is_array($val) ? $this->isEmpty($val) : empty($val);
		if(!$empty)
			return false;
	}
	return $empty;
}

function pruneTree($tree){
	$rtree = array();
	foreach($tree as $key => $val){
		if(is_array($val)){
			$tmp = $this->pruneTree($val);
			if(!$this->isEmpty($tmp))
				$rtree[$key] = $tmp;
		}else if(!empty($val))
			$rtree[$key] = $val;
	}
	return $this->isEmpty($rtree) ? array() : $rtree;
}

The above is copied from a class but I’m sure you can make it work as standalone functions if need be.

isEmpty() will check if a whole tree is empty by way of recursion. pruneTree() will make use of isEmpty to get rid of all empty items and nodes. This is basically as simple as recursion goes (a hell of a lot simpler than the above JS), not much to add here.


Related Posts

Tags: , ,