Background

Here at Jaypan, we have recently created a site that imported thousands of items of content into Drupal, creating Entities out of each of them. However, the imported data was incomplete, and each entity needed to be manually updated with information specific to the entity. Going through the Entity edit pages one by one would have taken days, if not weeks. As such, we needed to create a form that allowed for the editing of multiple entities at once, with search filters allowing for the entities to be filtered down to specific Entities that were of higher priority.

This tutorial will show this technique, showing how to create a multiple node edit form with search filters, allowing for the editing of multiple nodes at once.

Requirements

In order to understand this tutorial, you will need an understanding of how to create modules in Drupal 7, an understanding of the Drupal menu system, and a strong understanding of the Drupal Form API, none of which are covered in this tutorial.

This tutorial also uses a custom content type, named 'People', with some fields added to it. The People content type is used to create nodes with information about a given person. In order to be able to follow along with this tutorial, I have exported this content type and its fields using the Features module. You can download the export here. You can then use the features module to import the content type and fields into your system, which will be the same content type and fields used in this tutorial.

The 'People' content type contains the following fields:

  • Nickname: A textfield, allowing users to enter a nickname for the person
  • Image: An image field, allowing users to upload an image of the person
  • Personality: A checkboxes field, allowing users to select multiple personality types for the person
  • Sex: A radio element allowing users to select the sex of the person

You will probably also want to download and install the Devel module and the Devel Generate module that it contains, and use the Devel Generate module to generate some nodes of the People content type, to use for testing with this tutorial.

Finally, note that the entire code for this tutorial is listed at the end of the tutorial, so if something gets lost in context during any of the steps, you can refer to the entire code to see how it all fits together.

Overview

In the tutorial, we will create the page in a module with the key people_edit. All hooks will be based off this module key.

This tutorial will cover the following steps:

  1. Creating a menu callback path
  2. Creating a query to load all nodes
  3. Creating form elements for the given nodes
  4. Themeing the form elements into an HTML table
  5. Saving the submitted data to the respective Nodes
  6. Adding a search/filter form to filter the selected data
  7. Add submit handlers for our search form
  8. Altering the query from step 2 to filter the listed Nodes according to the user's selection
  9. (Optional) Add a CSS sheet to clean up our search form

Step 1: Creating a menu callback path

The first thing we need to do for this tutorial is to create a menu callback path, so that our form can be accessed. As this form will be an admin page, we will want to put the link in the admin section of the site, and we will give access to users who have permission to administer nodes:

span class="st0">'admin/content/people_edit''title' => 'Edit people',
		'page callback' => 'drupal_get_form',
		'page arguments''people_edit_multiple_people_form'),
		'access arguments''administer nodes'

Step 2:Creating a query to load all nodes

When we first access the page, we want to list all nodes of our content type. Later in the tutorial we will add functionality to filter these nodes according to our requirements, but the initial page load should show all nodes. To retrieve the nodes, we will use the following process:

  1. Use a db_select() query to retrieve the NIDs for the nodes we want to retrieve. This query will also handle our pager query, as well as our table select, allowing the table to be sorted by columns
  2. Load the nodes using node_load()

When creating our query, we will left join all the field_data_field_*** tables to the query. This will appear to be superfluous at first, as we will not be using these tables in our initial query, however they are necessary for the tablesort, and for the filtering that will come later in the tutorial.

One other thing to note is that the TableSort requires the HTML table header to be defined before the query, as it is added to the query allowing for the sortable columns.

// Define the header of hour HTML table, to be used in the HTML table
// as well as in our query, to allow for sortable columns
// The 'Process' column will contain a single checkbox for each row
	// so that rows can be selected to be processed. Without this, every
	// node in the row would be processed upon submit, which sometimes
	// will not be ideal
	t('Process'),
	// The  'Name' column will be sortable, and as we include the 'sort'
	// attribute, will be the default method used to sort the table
'data' => t('Name'), 'field' => 'node.title', 'sort' => 'asc'),
	// 'Nickname' will also be a sortable column
'data' => t('Nickname'), 'field' => 'nickname.field_people_nickname_value'),
	// The 'Image' column will not be sortable, as it doesn't logically make sense
	t('Image'),
	// The 'Personality' column will contain multiple values (as it is checkboxes), so
	// it does not make sense to have it sortable
	t('Personality'),
	// The 'Sex' column will only contain two fields, 'male' and 'female' (and/or empty)
	// and therefore sorting it does make sense.
'data' => t('Sex'), 'field' => 'sex.field_people_sex_value'),
);
 
// Next, we build our query
 
// Select from the node table, extending our query with
// PagerDefault (for pager queries) as well as with TableSort
// (for our sortable table columns)
'node', 'node''PagerDefault''TableSort');
// We only need to recover a single field, NID, as this will be used to load our nodes
$query->fields('node''nid'));
// We only want nodes of type People'
$query->condition('node.type', 'people');
// The query needs to be distinct. Without a distinct query, the same NID can be returned
// multiple times due to the joining of the {field_data_field_people_personality} table, which
// can contain multiple values for each node
$query->distinct();
// Next we join our various {field_data_field_people_***} tables
'field_data_field_people_image', 'people_image', 'people_image.entity_id = node.nid''field_data_field_people_nickname', 'nickname', 'nickname.entity_id = node.nid''field_data_field_people_personality', 'personality', 'personality.entity_id = node.nid''field_data_field_people_sex', 'sex', 'sex.entity_id = node.nid');
// Here we use the $header element we defined above to sort the table.
/** We will add our conditions here later on in the tutorial, allowing for the list of nodes to be filtered by user selection **/
 
// Finally, we execute our query and save the results:

Step 3: Creating form elements for the given nodes

Now that we have created a query to get our NIDs (Node IDs), we will loop through the returned NIDs, use them to load their respective Nodes, then create form elements for each Node, setting the default value for the form element to the value contained in the node, if one exists.

A couple things to note:

  • This part of the form will be contained in an element named 'results'. This is to differentiate it from 'search', which we will add later on.
  • We will make use of the #tree attribute of the Form API. If you do not understand how this attribute works, you should spend a few minutes reading up on it
  • The form elements will be themed into an HTML table in the next step of the tutorial

span class="coMULTI">/** Note that the Search part of the form will be inserted here.
	     This will come later in the tutorial **/
 
	// We set the options that will be used for Personality checkboxes.
	// The array keys for this array need to match the array keys that were
	// set when creating the field in the Field API. Note that we will need
	// this array for both the results table, as well as the search form, which
	// is why we create it here, rather than directly defining it in the element
	// definitions.
'happy' => t('Happy'),
		'positive' => t('Positive'),
		'foolish' => t('Foolish'),
		'clever' => t('Clever'),
	);
	// We set the options that will be used for Sex radios.
	// The array keys for this array need to match the array keys that were
	// set when creating the field in the Field API.  Note that we will need
	// this array for both the results table, as well as the search form, which
	// is why we create it here, rather than directly defining it in the element
	// definitions.
'male' => t('Male'),
		'female' => t('Female'),
	);
 
	// Build the list of form elements for the retrieved nodes
'results''#tree'// The results table will be the HTML table that lists all of our
	//  nodes. We need the $header variable we created for our
	// database query, as this variable will also be used to generate
	// our HTML table. We don't want to render this header however
	// so we pass it as an attribute, prefixed with the # symbol.
'results']['results_table''#theme' => 'people_edit_multiple_people_form_table',
		'#header'// We create an empty array of $nids. These will be used to
	// build checkboxes that will be used to mark whether or not
	// a given row should be processed or not, as the user may
	// not want to process every row in the table.
// We load the node that the NID represents
// We add the NID to the $nids array, giving
			// it an empty value. The reason for this empty value is that
			// these checkboxes will not need any text, as they will be
			// in the Processed column.
			$nids[$node->nid] = '';
			// Next, we store the node for use in our submit function
			// as we will be altering this node before saving it.
'results']['people''#type' => 'value',
				'#value' => $node,
			);
			//  The Person's name will be displayed in the table as an identifier
			// with a link to the Node page for that person, in case the user
			// wants to go to the given page
'results']['results_table'][$node->nid]['name''#markup' => l($node->title, 'node/' . $node->nid),
			);
			// Next we create a form element for the Person's nickname,
			// populating it with the value from the $node if one exists.
			// As the Field (from the Field API) is a text field, we create
			// a textfield
'results']['results_table'][$node->nid]['nickname''#type' => 'textfield',
				'#default_value''value'] : '',
			);
			// Next, we create a file upload element for the image, passing it
			// some validators to ensure it is an image with an image extension.
			// If the field is already populated in the node, we pass the FID (File ID)
			// of the image as the default value.
'results']['results_table'][$node->nid]['people_image''#type' => 'managed_file',
				'#upload_location' => 'public://people_images/',
				'#upload_validators''file_validate_extensions''jpg jpeg gif png'),
					'file_validate_is_image''#default_value''fid'// The next field we will create is the Personality field. As this Field is defined as
			// a list of checkboxes in the Field API, there can be multiple values for the field.
			// So we need to loop through the values in the $node (if any) and save the to
			// an array to be used as our defaults.
'value'];
				}
			}
			// Next we create our form element, filling it with the default values from above
'results']['results_table'][$node->nid]['personality''#type' => 'checkboxes',
				'#options''#default_value'// The last element we create is the radios for sex. As this element is
			// defined as radios through the Form API, it can only hold a single
			// value, and therefore we do not need to loop through the $node
			// values.
'results']['results_table'][$node->nid]['sex''#type' => 'radios',
				'#options''#default_value''value'// We only want to create the next form elements if any nodes were retrieved.
	// This is because the elements have no relevance in the situation that no
	// nodes were found, as the table will be empty, and there will be no nodes to
	// process, no pager, and no disclaimer necessary. As such, we check to see
	// if any $nids were added to the $nids array, and if there were, the form
	// elements are created
// We use the $nids array we have created to create checkboxes
		// that will be used in our 'Processed' column. Only rows that have this
		// this box checked will be processed, as explained earlier. 
'results']['results_table']['nodes_to_process''#type' => 'checkboxes',
			'#options' => $nids,
		);
		// We add the pager to the table. This will be automatically generated
		// due to extending our query with PagerDefault
'results']['results_table']['pager''#theme' => 'pager',
		);
		// We add a disclaimer about nodes needing to have the 'Processed' checkbox
		// checked, so our users understand this point
'results']['results_table']['disclaimer''#markup' => t('Only rows that have the "Process" checkbox checked will be saved'),
			'#prefix' => '<p>',
			'#suffix' => '</p>',
		);
 
		// Lastly, we create a submit button, and pass it a submit function that will only
		// be called when this button is clicked. This is because there will be other submit
		// buttons in the 'search' area of the form, and each button serves a different
		// purpose, so we don't want the submit function to be called when the other
		// buttons are clicked.
'results']['save_nodes''#type' => 'submit',
			'#value' => t('Save people'),
			'#submit''people_edit_multiple_people_save_submit'

Step 4: Themeing the form elements into an HTML table

When loading the form created in the previous step, you will notice that all the form elements are simply listed one after another in a vertical list on the page. This is difficult to read, and not what we are looking for. We want to theme these form elements into an HTML table, making the form easier to work with.

As such, we will need to create a new theme function to handle the themeing of this table. To understand the theme function that needs to be created, let's review this form element from the previous step:

span class="st0">'results']['results_table''#theme' => 'people_edit_multiple_people_form_table',
	'#header'

You'll notice that we set the #theme attribute for this element to 'people_edit_multiple_people_form_table'. We first need to register this theme function using hook_theme(). As we are themeing a table, we will define the 'render element' attribute in hook_theme() and set it to 'form'. Whenever themeing forms, the 'render element' is used, and not the 'variables' element.

span class="st0">'people_edit_multiple_people_form_table''render element' => 'form',
		),
	);
}

Next we need to define the function that will be called when using this theme function. The function name is the theme key prefixed with theme_. In this case, theme_people_edit_multiple_people_form_table():

span class="co1">// First we retrieve the form data from the $variables element. The root
	// of this form is the element on which we added the #theme key to,
	// so for our form, the root will be the $form['results']['results_table'] element
'form'];
 
	// Next we retrieve the $header variable. This is the variable that was
	// created ahead of our query, back in step 2
'#header'];
	// We now create an empty array for table rows. We will add the rows
	// to this table in a loop below
// $form['nodes_to_process'] will only exist if any Nodes were retrieved
	// in our query. So we need to check if it exists, before looping through it.
'nodes_to_process']))
	{
		// $form['nodes_to_process'] exists. This array will contain a number of
		// array keys inside it. Some will be meta data, that will be prefixed with
		// the # symbol. The rest of the array keys contained within will each
		// represent a single checkbox, with the array key being the NID for that
		// checkbox. The NIDs will not be prefixed with the # symbol.
		// The function element_children() loops through all children of an array,
		// returning only the keys that are not prefixed with the # symbol. This
		// means that we will get the NID for each row in our table. We can then
		// use this NID to render the various form elements into our table.
'nodes_to_process'// We create an empty array to hold our row data. Each element
			// of this array will contain a single cell in our HTML table.
// Column 1: The 'Process' checkbox
'nodes_to_process'][$nid]);
			// Column 2: The Person's name (a link to the Node page)
'name']);
			// Column 3: The textfield form element for nickname
'nickname']);
			// Column 4: The file upload element for the Person's image
'people_image']);
			// Column 4: The checkboxes element for the Person's personality
'personality']);
			// Column 5: The radios element for the Person's sex
'sex']);
 
			// Finally we add the row to our array of rows
			$rows[] = $row;
		}
	}
 
	// We now take the $header and $rows we have built, and use these to build an
	// HTML table using theme_table(), passing in an empty value if no rows were found.
	$output = theme('table''header''rows' => $rows, 'empty' => t('No results found')));
 
	// $form['pager'] and $form['disclaimer'] will only be set if any nodes were
	// retrieved. If any nodes were retrieved, then there will be values in the $rows
	// array. So we check to see if there are any rows in $rows, and if there are, we
	// render the pager and disclaimer.
'pager''disclaimer']);
	}
 
	// Finally, we return the HTML table, our pager, and our disclaimer

Step 5: Saving the submitted data to the respective Nodes

Now that we have a form with an HTML table that lists rows for each node we want to edit, we need to do something with the submitted values. So we will create the function people_edit_multiple_people_save_submit() that we defined as the submit handler for our submit button.

In this function, we will loop through the nodes, and if the 'Processed' checkbox has been checked, we will save the submitted values to the node. Note that we will for the most part want to overwrite the existing values for the node, with the exception of the image element, which needs some special processing to handle uploaded images.

span class="co1">// We create an array to hold the names of People who have been updated,
	// so as to provide a message to users after submit telling them which rows
	// were updated.
// In this submit function, we only want to use the values from the $form['results']
	// section of the form. These are contained in $form_state['values']['results'] as we set
	// $form['results']['#tree'] to be TRUE in our form definition.
'values']['results'];
 
	// Next we will loop through the values in the 'nodes_to_process' element, as we only
	// want to process rows that have had this box checked. The value will either be 0
	// if the box was not checked, or the NID of the row if the box was checked.
'results_table']['nodes_to_process'// We check the value. If it is an NID, it will be true
// We load the $node object for the row, that we saved in our form definition
			// in $form['results']['people'][$nid]. We will make our changes to this $node
			// object (called $person below) and save the $node after making our changes
			$person = $values['people'][$nid];
 
			// First we check if a value has been submitted for nickname
'results_table'][$nid]['nickname']))
			{
				// A value was submitted for nickname, so we set the value on the
				// node to the submitted value
'value'] = $values['results_table'][$nid]['nickname'// A value was not submitted for the nickname, so we want the
				// field on the $node to be empty (overwriting any values that
				// may have previously existed)
// The next section to deal with is the image upload element.
			// $values['results_table'][$nid]['people_image'] will either contain 0 (zero)
			// if no image previously existed and nothing was uploaded, or it will contain
			// the FID (File ID) if the uploaded image if one previously existed, or the FID
			// of the newly uploaded image if one was uploaded. As such, this first check
			// checks:
			//  * Does a FID exists for the field? If so:
			//  * Was there previously not an image saved to the node, or if there was, is
			//    the FID for the existing image different from the submitted FID?
			//
			// If the above two conditions are true, we need to save the uploaded image
			// to the node.
'results_table'][$nid]['people_image''results_table'][$nid]['people_image''fid']))
			{
				// First we load the file from the submitted FID
'results_table'][$nid]['people_image']);
				// Next we set the status of the file to FILE_STATUS_PERMANENT.
				// If we do not do this, the file will be removed from the system on
				// a later cron run.
// Now we save the file with the new status
// And we have to add a 'usage' for this file, or we will get various
				// errors in the system with other forms.
'people_edit', 'node', $person->nid);
				// Finally we save our newly uploaded image to the $node
// If $values['results_table'][$nid]['people_image'] is set to 0 (zero), it means
			// that no image was submitted. In the case that an image previously existed,
			// we need to remove the existing image from the node, as it has been removed
			// in the form
'results_table'][$nid]['people_image'// We load the file off the existing FID
'fid']);
				// If the file exists
// First we remove our usage of the file
'people_edit', 'node', $person->nid);
					// Now we delete the file
// Lastly we set the field on the $node to an empty array, since there is no more
				// image associated with the $node.
// The next field we deal with is Personality. Since this field can have multiple
			// values, we need to loop through the checkboxes to see if the box was checked.
			// Any submitted values will be saved to the $personalities array.
'results_table'][$nid]['personality'// $personality will only be TRUE if the box was checked, and will
				// contain the key for the submitted field
// We add the submitted value to the $personalities array
'value'] = $personality;
				}
			}
			// Finally we overwrite any existing values with the submitted values
			// (if any)
// The last thing we do is save the submitted value (if any) for the Sex field
			// to the $node.
'results_table'][$nid]['sex''value'] = $values['results_table'][$nid]['sex'// Now that we have updated the $node object, we save it, so as to
			// save our values.
// We want to show the user a list of updated Nodes, so we save the
			// updated person to the $updated array.
'node/' . $person->nid);
		}
	}
 
	// If any Nodes were updated, we create a message telling the user
	// which Nodes (People) were updated. Otherwise we give them a
	// message saying no Nodes were updated, which will be the case
	// if the user has not checked any of the 'Processed' checkboxes
'The following people were updated: !updated''!updated' => theme('item_list''items''No rows were selected to be updated'

Step 6: Adding a search/filter form to filter the selected data

Now that we have built our form and created the submit handler that saves the values to the system, we want to create a filter form allowing users to filter the list of shown nodes. This will allow the user to search for specific nodes or groups of nodes, making the form more usable.

Our search form will contain the following filters:

  • Image: filter nodes on whether an image has been uploaded or not
  • Personality: filter nodes to those that have the selected personality
  • Sex: filter nodes to those for the given sex
  • Name: filter nodes down to those whose title (name) start with the submitted text
  • Nickname: filter nodes down to those whose nickname start with the submitted text

Note the following:

  • That this part of the form will be contained in an element named 'search'. This is to differentiate it from 'results', which we defined earlier.
  • This section of the form will be a collapsible fieldset that will be collapsed by default, and expanded if any filters have been selected.
  • The form below is added to the people_edit_multiple_people_form() function defined in step 3. A comment was left in that code to show where this code will go.

// We will first get any $_GET variables that exist, however we do not want
// the 'q' variable (Drupal path), the 'sort' and 'order' variables (used for sorting
// table columns) or the 'page' variable (used for the pager). In the case that
// the search form has not been submitted, $params will be an empty array.
'q', 'sort', 'order', 'page'));
 
// This element is the collapsible fieldset that will contain the search form
'search''#type' => 'fieldset',
	'#title' => t('search'),
	'#collapsible'// We only want the fieldset collapsed if values have not been
	// submitted for the search form. If no values have been submitted,
	// $params will be an empty array, and the fieldset will be
	// collapsed
	'#collapsed''#tree'// We create our filter for whether or not an image has been attached to
// the nodes. The default value when the form has not been submitted is
// 'all', meaning all nodes are shown whether or not an images has been
// attached.
'search']['people_image''#type' => 'radios',
	'#title' => t('Image'),
	'#options''all' => t('All'),
		'complete' => t('Completed'),
		'incomplete' => t('Incomplete'),
	),
	// The default value is selected based on the submitted value for the form
	// or set to 'all' as a default
	'#default_value''people_image''people_image']]) ? $params['people_image'] : 'all',
);
 
// We create radios for the various personalities. We need to add the value for
// 'all' to the options, as the default will be that all nodes regardless of whether or
// or not any personalities have been saved to the node.
'search']['personality''#type' => 'radios',
	'#title' => t('Personality'),
	'#options''all' => t('All'// The default value is selected based on the submitted value for the form
	// or set to 'all' as a default
	'#default_value''personality''personality']]) ? $params['personality'] : 'all',
);
 
// We create radios for the various personalities. We need to add the value for
// 'all' to the options, as the default will be that all nodes regardless of whether or
// or not a sex has been saved to the node.
'search']['sex''#type' => 'radios',
	'#title' => t('Sex'),
	'#options''all' => t('All'// The default value is selected based on the submitted value for the form
	// or set to 'all' as a default
	'#default_value''sex''sex']]) ? $params['sex'] : 'all',
);
 
// We want users to be able to filter Nodes based on the name of
// the Person (the Node title).
'search']['name''#type' => 'textfield',
	'#title' => t('Name'),
	'#description' => t('Enter the start or all of a name'),
	// The default value is the submitted value for the form, or
	// an empty string when the form has not been submitted
	'#default_value''name']) ? $params['name'] : '',
);
 
 
// We want users to be able to filter Nodes based on the nickname of
// the Person
'search']['nickname''#type' => 'textfield',
	'#title' => t('Nickname'),
	'#description' => t('Enter the start or all of a name'),
	// The default value is the submitted value for the form, or
	// an empty string when the form has not been submitted
	'#default_value''nickname']) ? $params['nickname'] : '',
);
 
// We create a container to contain our buttons
'search']['buttons''#type' => 'actions',
);
 
// We add a submit button to allow for the filtering of the results. This
// button needs to have its own submit handler,
// people_edit_multiple_people_search_submit() as there will be
// multiple submit buttons on the page, and we only want this handler
// to be called when this button is clicked.
'search']['buttons']['filter_results''#type' => 'submit',
	'#value' => t('Filter results'),
	'#submit''people_edit_multiple_people_search_submit'),
);
 
// We add a submit button to allow for the resetting of the search form.
// This button needs to have its own submit handler,
// people_edit_multiple_people_reset_submit() as there will be
// multiple submit buttons on the page, and we only want this handler
// to be called when this button is clicked.
'search']['buttons']['reset_filters''#type' => 'submit',
	'#value' => t('Reset filters'),
	'#submit''people_edit_multiple_people_reset_submit'),
);

Step 7: Add submit handlers for our search form

In step 6, we added two submit buttons for our search form. The first filters the search results, the second resets the form. Each of these submit handlers will perform a redirect to the current page. The filter button redirects to the current page with various $_GET variables, that we retrieved in the last step using drupal_get_query_parameters(), and the reset button redirects to the current page without any $_GET variables, which is the default when the page is first accessed.

/**
 * Our submit handler for the search/filter button in the
 * search part of the form.
 */// First we create an empty array to hold our submitted values
// Next we retrieve the submitted values
'values']['search'];
 
	// We only need to set $_GET variables for
	//  these first three elements if they are not
	// set to the default, 'all'
'people_image'] != 'all')
	{
		$params['people_image'] = $values['people_image''personality'] != 'all')
	{
		$params['personality'] = $values['personality''sex'] != 'all')
	{
		$params['sex'] = $values['sex'];
	}
 
	// We only need to set the $_GET variable for
	// the next two elements if a value was entered
	// for the field
'name']))
	{
		$params['name'] = $values['name''nickname']))
	{
		$params['nickname'] = $values['nickname'];
	}
 
	// Finally we redirect the user with the submitted parameters.
	// These parameters will then be used as defaults in the form,
	// and also used to filter the list of People (Nodes) shown.
'redirect''admin/content/people_edit''query' => $params));
}
 
/**
 * Our submit handler for the reset button in the
 * search part of the form.
 */// As the reset button has been clicked, we want to redirect users
	// to the same page, without any parameters.
'redirect'] = 'admin/content/people_edit';
}

Step 8: Altering the query from step 2 to filter the listed Nodes according to the user's selection

The final step in the tutorial is to add conditions to our query that we created in step 2, so that the list of nodes is filtered by the submitted values. The following code will be added in the section from step 2, before the call to $query->execute(). A note was left in the code to indicate the location for this code.

The idea is that we want to add a condition for each submitted value, thereby filtering down the list of results to Nodes that fit this condition.

// If 'complete' or 'incomplete' was selected for 'Image', we want to show
// Nodes that respectively have an image attached, or don't.
'people_image''people_image''complete', 'incomplete''people_image'] == 'complete')
	{
		// people_image.entity will not be NULL if
		// an image has been uploaded for the Node
'people_image.entity_id''people_image'] == 'incomplete')
	{
		// people_image.entity_id will be NULL if
		// no image has been uploaded for the node
'people_image.entity_id');
	}
}
 
// We add a LIKE condition on the Node title, in order
// to retrieve any Nodes with a title that starts with (or are
// equal to) the submitted valued
'name''name'])))
{
	$query->condition('node.title''name'])) . '%', 'LIKE');
}
 
// We add a LIKE condition on nickname.field_people_nickname_value
// in order to retrieve any People with a nickname that starts with (or is
// equal to) the submitted value.
'nickname''nickname'])))
{
	$query->condition('nickname.field_people_nickname_value''nickname'])) . '%', 'LIKE');
}
 
// We add a condition to search for any People that have had the
// selected personality set to the Node.
'personality''personality']]))
{
	$query->condition('personality.field_people_personality_value', $params['personality']);
}
 
// We add a condition to search for any People that have had the
// selected sex set to the Node.
'sex''sex']]))
{
	$query->condition('sex.field_people_sex_value', $params['sex']);
}

Step 9 (optional): Add a CSS sheet to clean up our search form

This step is not required, but it will clean up the search form to make it a little more user friendly.

First, we attach the sheet to the search section of the form:

// Add a CSS sheet to the search form to clean up the display and make it
// more user friendly
'search']['#attached']['css''type' => 'file',
		// The css file will be located at [MODULES_FOLDER]/people_edit/css/
		'data''module', 'people_edit') . '/css/people_edit_multiple_people_form.css',
	),
);

And the contents of the CSS file:

#people-edit-multiple-people-form fieldset .form-type-radios
{
	float:left;
	margin-right:40px;
}
 
#people-edit-multiple-people-form fieldset .form-type-textfield
{
	clear:left;
}

Conclusion

So there it is, we have created a form that has two sections, a 'search' area and a 'results' area. The search form contains various filters that can be used to filter the results, and the results area contains a list of People (Nodes) that fit the selected filters. And when the form is submitted, the submitted values are saved to the given nodes.

Entire code

For posterity's sake, here is the entire code for the tutorial (minus the CSS):

span class="st0">'admin/content/people_edit''title' => 'Edit people',
		'page callback' => 'drupal_get_form',
		'page arguments''people_edit_multiple_people_form'),
		'access arguments''administer nodes'// First, set up some variables used in the form definition
 
	// We will first get any $_GET variables that exist, however we do not want
	// the 'q' variable (Drupal path), the 'sort' and 'order' variables (used for sorting
	// table columns) or the 'page' variable (used for the pager). In the case that
	// the search form has not been submitted, $params will be an empty array.
'q', 'sort', 'order', 'page'));
 
	// Define the header of hour HTML table, to be used in the HTML table
	// as well as in our query, to allow for sortable columns
// The 'Process' column will contain a single checkbox for each row
		// so that rows can be selected to be processed. Without this, every
		// node in the row would be processed upon submit, which sometimes
		// will not be ideal
		t('Process'),
		// The  'Name' column will be sortable, and as we include the 'sort'
		// attribute, will be the default method used to sort the table
'data' => t('Name'), 'field' => 'node.title', 'sort' => 'asc'),
		// 'Nickname' will also be a sortable column
'data' => t('Nickname'), 'field' => 'nickname.field_people_nickname_value'),
		// The 'Image' column will not be sortable, as it doesn't logically make sense
		t('Image'),
		// The 'Personality' column will contain multiple values (as it is checkboxes), so
		// it does not make sense to have it sortable
		t('Personality'),
		// The 'Sex' column will only contain two fields, 'male' and 'female' (and/or empty)
		// and therefore sorting it does make sense.
'data' => t('Sex'), 'field' => 'sex.field_people_sex_value'),
	);
 
	// We set the options that will be used for Personality checkboxes.
	// The array keys for this array need to match the array keys that were
	// set when creating the field in the Field API. Note that we will need
	// this array for both the results table, as well as the search form, which
	// is why we create it here, rather than directly defining it in the element
	// definitions.
'happy' => t('Happy'),
		'positive' => t('Positive'),
		'foolish' => t('Foolish'),
		'clever' => t('Clever'),
	);
	// We set the options that will be used for Sex radios.
	// The array keys for this array need to match the array keys that were
	// set when creating the field in the Field API.  Note that we will need
	// this array for both the results table, as well as the search form, which
	// is why we create it here, rather than directly defining it in the element
	// definitions.
'male' => t('Male'),
		'female' => t('Female'),
	);
 
	// Select from the node table, extending our query with
	// PagerDefault (for pager queries) as well as with TableSort
	// (for our sortable table columns)
'node', 'node''PagerDefault''TableSort');
	// We only need to recover a single field, NID, as this will be used to load our nodes
	$query->fields('node''nid'));
	// We only want nodes of type People'
	$query->condition('node.type', 'people');
	// The query needs to be distinct. Without a distinct query, the same NID can be returned
	// multiple times due to the joining of the {field_data_field_people_personality} table, which
	// can contain multiple values for each node
	$query->distinct();
	// Next we join our various {field_data_field_people_***} tables
'field_data_field_people_image', 'people_image', 'people_image.entity_id = node.nid''field_data_field_people_nickname', 'nickname', 'nickname.entity_id = node.nid''field_data_field_people_personality', 'personality', 'personality.entity_id = node.nid''field_data_field_people_sex', 'sex', 'sex.entity_id = node.nid');
	// Here we use the $header element we defined above to sort the table.
// If 'complete' or 'incomplete' was selected for 'Image', we want to show
	// Nodes that respectively have an image attached, or don't.
'people_image''people_image''complete', 'incomplete''people_image'] == 'complete')
		{
			// people_image.entity will not be NULL if
			// an image has been uploaded for the Node
'people_image.entity_id''people_image'] == 'incomplete')
		{
			// people_image.entity_id will be NULL if
			// no image has been uploaded for the node
'people_image.entity_id');
		}
	}
 
	// We add a LIKE condition on the Node title, in order
	// to retrieve any Nodes with a title that starts with (or are
	// equal to) the submitted valued
'name''name'])))
	{
		$query->condition('node.title''name'])) . '%', 'LIKE');
	}
 
	// We add a LIKE condition on nickname.field_people_nickname_value
	// in order to retrieve any People with a nickname that starts with (or is
	// equal to) the submitted value.
'nickname''nickname'])))
	{
		$query->condition('nickname.field_people_nickname_value''nickname'])) . '%', 'LIKE');
	}
 
	// We add a condition to search for any People that have had the
	// selected personality set to the Node.
'personality''personality']]))
	{
		$query->condition('personality.field_people_personality_value', $params['personality']);
	}
 
	// We add a condition to search for any People that have had the
	// selected sex set to the Node.
'sex''sex']]))
	{
		$query->condition('sex.field_people_sex_value', $params['sex']);
	}
 
	// Finally, we execute our query and save the results:
/** Form Definition **/
 
	// First, we build the search form
	// This element is the collapsible fieldset that will contain the search form
'search''#type' => 'fieldset',
		'#title' => t('search'),
		'#collapsible'// We only want the fieldset collapsed if values have not been
		// submitted for the search form. If no values have been submitted,
		// $params will be an empty array, and the fieldset will be
		// collapsed
		'#collapsed''#tree'// We create our filter for whether or not an image has been attached to
	// the nodes. The default value when the form has not been submitted is
	// 'all', meaning all nodes are shown whether or not an images has been
	// attached.
'search']['people_image''#type' => 'radios',
		'#title' => t('Image'),
		'#options''all' => t('All'),
			'complete' => t('Completed'),
			'incomplete' => t('Incomplete'),
		),
		// The default value is selected based on the submitted value for the form
		// or set to 'all' as a default
		'#default_value''people_image''people_image']]) ? $params['people_image'] : 'all',
	);
 
	// We create radios for the various personalities. We need to add the value for
	// 'all' to the options, as the default will be that all nodes regardless of whether or
	// or not any personalities have been saved to the node.
'search']['personality''#type' => 'radios',
		'#title' => t('Personality'),
		'#options''all' => t('All'// The default value is selected based on the submitted value for the form
		// or set to 'all' as a default
		'#default_value''personality''personality']]) ? $params['personality'] : 'all',
	);
 
	// We create radios for the various personalities. We need to add the value for
	// 'all' to the options, as the default will be that all nodes regardless of whether or
	// or not a sex has been saved to the node.
'search']['sex''#type' => 'radios',
		'#title' => t('Sex'),
		'#options''all' => t('All'// The default value is selected based on the submitted value for the form
		// or set to 'all' as a default
		'#default_value''sex''sex']]) ? $params['sex'] : 'all',
	);
 
	// We want users to be able to filter Nodes based on the name of
	// the Person (the Node title).
'search']['name''#type' => 'textfield',
		'#title' => t('Name'),
		'#description' => t('Enter the start or all of a name'),
		// The default value is the submitted value for the form, or
		// an empty string when the form has not been submitted
		'#default_value''name']) ? $params['name'] : '',
	);
 
 
	// We want users to be able to filter Nodes based on the nickname of
	// the Person
'search']['nickname''#type' => 'textfield',
		'#title' => t('Nickname'),
		'#description' => t('Enter the start or all of a name'),
		// The default value is the submitted value for the form, or
		// an empty string when the form has not been submitted
		'#default_value''nickname']) ? $params['nickname'] : '',
	);
 
	// We create a container to contain our buttons
'search']['buttons''#type' => 'actions',
	);
 
	// We add a submit button to allow for the filtering of the results. This
	// button needs to have its own submit handler,
	// people_edit_multiple_people_search_submit() as there will be
	// multiple submit buttons on the page, and we only want this handler
	// to be called when this button is clicked.
'search']['buttons']['filter_results''#type' => 'submit',
		'#value' => t('Filter results'),
		'#submit''people_edit_multiple_people_search_submit'),
	);
 
	// We add a submit button to allow for the resetting of the search form.
	// This button needs to have its own submit handler,
	// people_edit_multiple_people_reset_submit() as there will be
	// multiple submit buttons on the page, and we only want this handler
	// to be called when this button is clicked.
'search']['buttons']['reset_filters''#type' => 'submit',
		'#value' => t('Reset filters'),
		'#submit''people_edit_multiple_people_reset_submit'),
	);
 
	// Add a CSS sheet to the search form to clean up the display and make it
	// more user friendly
'search']['#attached']['css''type' => 'file',
			'data''module', 'people_edit') . '/css/people_edit_multiple_people_form.css',
		),
	);
 
 
	// Build the list of form elements for the retrieved nodes
'results''#tree'// The results table will be the HTML table that lists all of our
	//  nodes. We need the $header variable we created for our
	// database query, as this variable will also be used to generate
	// our HTML table. We don't want to render this header however
	// so we pass it as an attribute, prefixed with the # symbol.
'results']['results_table''#theme' => 'people_edit_multiple_people_form_table',
		'#header'// We create an empty array of $nids. These will be used to
	// build checkboxes that will be used to mark whether or not
	// a given row should be processed or not, as the user may
	// not want to process every row in the table.
// We load the node that the NID represents
// We add the NID to the $nids array, giving
			// it an empty value. The reason for this empty value is that
			// these checkboxes will not need any text, as they will be
			// in the Processed column.
			$nids[$node->nid] = '';
			// Next, we store the node for use in our submit function
			// as we will be altering this node before saving it.
'results']['people''#type' => 'value',
				'#value' => $node,
			);
			//  The Person's name will be displayed in the table as an identifier
			// with a link to the Node page for that person, in case the user
			// wants to go to the given page
'results']['results_table'][$node->nid]['name''#markup' => l($node->title, 'node/' . $node->nid),
			);
			// Next we create a form element for the Person's nickname,
			// populating it with the value from the $node if one exists.
			// As the Field (from the Field API) is a text field, we create
			// a textfield
'results']['results_table'][$node->nid]['nickname''#type' => 'textfield',
				'#default_value''value'] : '',
			);
			// Next, we create a file upload element for the image, passing it
			// some validators to ensure it is an image with an image extension.
			// If the field is already populated in the node, we pass the FID (File ID)
			// of the image as the default value.
'results']['results_table'][$node->nid]['people_image''#type' => 'managed_file',
				'#upload_location' => 'public://people_images/',
				'#upload_validators''file_validate_extensions''jpg jpeg gif png'),
					'file_validate_is_image''#default_value''fid'// The next field we will create is the Personality field. As this Field is defined as
			// a list of checkboxes in the Field API, there can be multiple values for the field.
			// So we need to loop through the values in the $node (if any) and save the to
			// an array to be used as our defaults.
'value'];
				}
			}
			// Next we create our form element, filling it with the default values from above
'results']['results_table'][$node->nid]['personality''#type' => 'checkboxes',
				'#options''#default_value'// The last element we create is the radios for sex. As this element is
			// defined as radios through the Form API, it can only hold a single
			// value, and therefore we do not need to loop through the $node
			// values.
'results']['results_table'][$node->nid]['sex''#type' => 'radios',
				'#options''#default_value''value'// We only want to create the next form elements if any nodes were retrieved.
	// This is because the elements have no relevance in the situation that no
	// nodes were found, as the table will be empty, and there will be no nodes to
	// process, no pager, and no disclaimer necessary. As such, we check to see
	// if any $nids were added to the $nids array, and if there were, the form
	// elements are created
// We use the $nids array we have created to create checkboxes
		// that will be used in our 'Processed' column. Only rows that have this
		// this box checked will be processed, as explained earlier. 
'results']['results_table']['nodes_to_process''#type' => 'checkboxes',
			'#options' => $nids,
		);
		// We add the pager to the table. This will be automatically generated
		// due to extending our query with PagerDefault
'results']['results_table']['pager''#theme' => 'pager',
		);
		// We add a disclaimer about nodes needing to have the 'Processed' checkbox
		// checked, so our users understand this point
'results']['results_table']['disclaimer''#markup' => t('Only rows that have the "Process" checkbox checked will be saved'),
			'#prefix' => '<p>',
			'#suffix' => '</p>',
		);
 
		// Lastly, we create a submit button, and pass it a submit function that will only
		// be called when this button is clicked. This is because there will be other submit
		// buttons in the 'search' area of the form, and each button serves a different
		// purpose, so we don't want the submit function to be called when the other
		// buttons are clicked.
'results']['save_nodes''#type' => 'submit',
			'#value' => t('Save people'),
			'#submit''people_edit_multiple_people_save_submit'/**
 * Our submit handler for the search/filter button in the
 * search part of the form.
 */// First we create an empty array to hold our submitted values
// Next we retrieve the submitted values
'values']['search'];
 
	// We only need to set $_GET variables for
	//  these first three elements if they are not
	// set to the default, 'all'
'people_image'] != 'all')
	{
		$params['people_image'] = $values['people_image''personality'] != 'all')
	{
		$params['personality'] = $values['personality''sex'] != 'all')
	{
		$params['sex'] = $values['sex'];
	}
 
	// We only need to set the $_GET variable for
	// the next two elements if a value was entered
	// for the field
'name']))
	{
		$params['name'] = $values['name''nickname']))
	{
		$params['nickname'] = $values['nickname'];
	}
 
	// Finally we redirect the user with the submitted parameters.
	// These parameters will then be used as defaults in the form,
	// and also used to filter the list of People (Nodes) shown.
'redirect''admin/content/people_edit''query' => $params));
}
 
/**
 * Our submit handler for the reset button in the
 * search part of the form.
 */// As the reset button has been clicked, we want to redirect users
	// to the same page, without any parameters.
'redirect'] = 'admin/content/people_edit'// We create an array to hold the names of People who have been updated,
	// so as to provide a message to users after submit telling them which rows
	// were updated.
// In this submit function, we only want to use the values from the $form['results']
	// section of the form. These are contained in $form_state['values']['results'] as we set
	// $form['results']['#tree'] to be TRUE in our form definition.
'values']['results'];
 
	// Next we will loop through the values in the 'nodes_to_process' element, as we only
	// want to process rows that have had this box checked. The value will either be 0
	// if the box was not checked, or the NID of the row if the box was checked.
'results_table']['nodes_to_process'// We check the value. If it is an NID, it will be true
// We load the $node object for the row, that we saved in our form definition
			// in $form['results']['people'][$nid]. We will make our changes to this $node
			// object (called $person below) and save the $node after making our changes
			$person = $values['people'][$nid];
 
			// First we check if a value has been submitted for nickname
'results_table'][$nid]['nickname']))
			{
				// A value was submitted for nickname, so we set the value on the
				// node to the submitted value
'value'] = $values['results_table'][$nid]['nickname'// A value was not submitted for the nickname, so we want the
				// field on the $node to be empty (overwriting any values that
				// may have previously existed)
// The next section to deal with is the image upload element.
			// $values['results_table'][$nid]['people_image'] will either contain 0 (zero)
			// if no image previously existed and nothing was uploaded, or it will contain
			// the FID (File ID) if the uploaded image if one previously existed, or the FID
			// of the newly uploaded image if one was uploaded. As such, this first check
			// checks:
			//  * Does a FID exists for the field? If so:
			//  * Was there previously not an image saved to the node, or if there was, is
			//    the FID for the existing image different from the submitted FID?
			//
			// If the above two conditions are true, we need to save the uploaded image
			// to the node.
'results_table'][$nid]['people_image''results_table'][$nid]['people_image''fid']))
			{
				// First we load the file from the submitted FID
'results_table'][$nid]['people_image']);
				// Next we set the status of the file to FILE_STATUS_PERMANENT.
				// If we do not do this, the file will be removed from the system on
				// a later cron run.
// Now we save the file with the new status
// And we have to add a 'usage' for this file, or we will get various
				// errors in the system with other forms.
'people_edit', 'node', $person->nid);
				// Finally we save our newly uploaded image to the $node
// If $values['results_table'][$nid]['people_image'] is set to 0 (zero), it means
			// that no image was submitted. In the case that an image previously existed,
			// we need to remove the existing image from the node, as it has been removed
			// in the form
'results_table'][$nid]['people_image'// We load the file off the existing FID
'fid']);
				// If the file exists
// First we remove our usage of the file
'people_edit', 'node', $person->nid);
					// Now we delete the file
// Lastly we set the field on the $node to an empty array, since there is no more
				// image associated with the $node.
// The next field we deal with is Personality. Since this field can have multiple
			// values, we need to loop through the checkboxes to see if the box was checked.
			// Any submitted values will be saved to the $personalities array.
'results_table'][$nid]['personality'// $personality will only be TRUE if the box was checked, and will
				// contain the key for the submitted field
// We add the submitted value to the $personalities array
'value'] = $personality;
				}
			}
			// Finally we overwrite any existing values with the submitted values
			// (if any)
// The last thing we do is save the submitted value (if any) for the Sex field
			// to the $node.
'results_table'][$nid]['sex''value'] = $values['results_table'][$nid]['sex'// Now that we have updated the $node object, we save it, so as to
			// save our values.
// We want to show the user a list of updated Nodes, so we save the
			// updated person to the $updated array.
'node/' . $person->nid);
		}
	}
 
	// If any Nodes were updated, we create a message telling the user
	// which Nodes (People) were updated. Otherwise we give them a
	// message saying no Nodes were updated, which will be the case
	// if the user has not checked any of the 'Processed' checkboxes
'The following people were updated: !updated''!updated' => theme('item_list''items''No rows were selected to be updated'// First we retrieve the form data from the $variables element. The root
	// of this form is the element on which we added the #theme key to,
	// so for our form, the root will be the $form['results']['results_table'] element
'form'];
 
	// Next we retrieve the $header variable. This is the variable that was
	// created ahead of our query, back in step 2
'#header'];
	// We now create an empty array for table rows. We will add the rows
	// to this table in a loop below
// $form['nodes_to_process'] will only exist if any Nodes were retrieved
	// in our query. So we need to check if it exists, before looping through it.
'nodes_to_process']))
	{
		// $form['nodes_to_process'] exists. This array will contain a number of
		// array keys inside it. Some will be meta data, that will be prefixed with
		// the # symbol. The rest of the array keys contained within will each
		// represent a single checkbox, with the array key being the NID for that
		// checkbox. The NIDs will not be prefixed with the # symbol.
		// The function element_children() loops through all children of an array,
		// returning only the keys that are not prefixed with the # symbol. This
		// means that we will get the NID for each row in our table. We can then
		// use this NID to render the various form elements into our table.
'nodes_to_process'// We create an empty array to hold our row data. Each element
			// of this array will contain a single cell in our HTML table.
// Column 1: The 'Process' checkbox
'nodes_to_process'][$nid]);
			// Column 2: The Person's name (a link to the Node page)
'name']);
			// Column 3: The textfield form element for nickname
'nickname']);
			// Column 4: The file upload element for the Person's image
'people_image']);
			// Column 4: The checkboxes element for the Person's personality
'personality']);
			// Column 5: The radios element for the Person's sex
'sex']);
 
			// Finally we add the row to our array of rows
			$rows[] = $row;
		}
	}
 
	// We now take the $header and $rows we have built, and use these to build an
	// HTML table using theme_table(), passing in an empty value if no rows were found.
	$output = theme('table''header''rows' => $rows, 'empty' => t('No results found')));
 
	// $form['pager'] and $form['disclaimer'] will only be set if any nodes were
	// retrieved. If any nodes were retrieved, then there will be values in the $rows
	// array. So we check to see if there are any rows in $rows, and if there are, we
	// render the pager and disclaimer.
'pager''disclaimer']);
	}
 
	// Finally, we return the HTML table, our pager, and our disclaimer
'people_edit_multiple_people_form_table''render element' => 'form',
		),
	);
}