Index: includes/form.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/form.inc,v retrieving revision 1.421 diff -u -p -r1.421 form.inc --- includes/form.inc 17 Dec 2009 21:59:31 -0000 1.421 +++ includes/form.inc 28 Dec 2009 08:53:03 -0000 @@ -2,6 +2,18 @@ // $Id: form.inc,v 1.421 2009/12/17 21:59:31 dries Exp $ /** + * Validate the section that is 1 level up from the button. + * @see _form_validate_skip() + */ +define('VALIDATE_SECTION_PARENT', -1); + +/** + * Validate the section that is 2 levels up from the button. + * @see _form_validate_skip() + */ +define('VALIDATE_SECTION_GRANDPARENT', -2); + +/** * @defgroup forms Form builder functions * @{ * Functions that build an abstract representation of a HTML form. @@ -897,7 +909,7 @@ function _form_validate(&$elements, &$fo // length if it's a string, and the item count if it's an array. // An unchecked checkbox has a #value of numeric 0, different than string // '0', which could be a valid value. - if ($elements['#required'] && (!count($elements['#value']) || (is_string($elements['#value']) && strlen(trim($elements['#value'])) == 0) || $elements['#value'] === 0)) { + if ($elements['#required'] && !_form_validate_skip($elements, $form_state) && (!count($elements['#value']) || (is_string($elements['#value']) && strlen(trim($elements['#value'])) == 0) || $elements['#value'] === 0)) { form_error($elements, $t('!name field is required.', array('!name' => $elements['#title']))); } @@ -935,7 +947,7 @@ function _form_validate(&$elements, &$fo } // Call any element-specific validators. These must act on the element // #value data. - elseif (isset($elements['#element_validate'])) { + elseif (isset($elements['#element_validate']) && !_form_validate_skip($elements, $form_state)) { foreach ($elements['#element_validate'] as $function) { if (function_exists($function)) { $function($elements, $form_state, $form_state['complete form']); @@ -947,6 +959,189 @@ function _form_validate(&$elements, &$fo } /** + * Check if an element's validation may be skipped. + * + * A form button may specify the #validate_sections property to limit form + * validation to form elements whose values reside within one of the specified + * sections of $form_state['values']. Such buttons are required to also define + * custom #submit handlers, which ONLY process those sections of + * $form_state['values']. + * + * Example: + * @code + * function foo_form() { + * $form = array( + * '#tree' => TRUE, + * 'title' => array( + * '#type' => 'textfield', + * '#title' => t('Title'), + * '#required' => TRUE, + * ), + * 'body' => array( + * '#type' => 'textarea', + * '#title' => t('Body'), + * '#required' => TRUE, + * ), + * 'a' => array( + * ... + * 'b' => array( + * ... + * 'c' => array( + * '#type' => 'textfield', + * '#required' => TRUE, + * ), + * ), + * ), + * 'submit1' => array( + * '#type' => 'submit', + * '#value' => t('Submit 1'), + * ), + * 'submit2' => array( + * '#type' => 'submit', + * '#value' => t('Submit 2'), + * '#validate_sections = array(array('body'), array('a', 'b')), + * '#submit' => array('foo_form_partial_submit'), + * ), + * ); + * } + * + * function foo_form_submit($form, &$form_state) { + * // This is the form's default submit handler and can assume everything in + * // the form has been validated. + * } + * + * function foo_form_partial_submit($form, &$form_state) { + * // This is the special submit handler for when 'submit2' is clicked. Only + * // the 'body', 'a[b]', and 'a[b][c]' elements have been validated. The + * // 'title' and 'a' elements have not been validated. We can use + * // $form_state['values']['body'] and $form_state['values']['a']['b'], + * // but we must be careful to not rely on any other part of + * // $form_state['values'] containing valid data. + * } + * @endcode + * + * This is intended primarily for use in multi-step forms (usually where the + * interim steps are submitted with AJAX), such as the "add another item" button + * for a multiple value field, where we don't want a not yet filled out but + * required field (such as the title) unrelated to the processing of the button + * click to cause a validation error, preventing a new item from being added to + * the form. + * + * Each section specified within #validate_sections is an array of successive + * keys to reach the desired part of $form_state['values']. This is the same as + * the #parents property of the corresponding element to be validated. + * Children and nested children of this element also get validated. Note that + * because this involves user input validation, #parents rather than + * #array_parents is the determining property. Be sure to understand the + * difference between these properties when setting #validate_sections. + * + * In addition to an array that mirrors a #parents array, a section can be set + * to a negative number indicating an ancestor of the button being clicked. For + * example, setting one of the sections to -3 means to take the button's + * #parents property and remove the last 3 entries. It is very common to want to + * set the validation section to the parent or grandparent of where the button + * is within the form, so these constants are provided: + * - VALIDATE_SECTION_PARENT: same as -1 + * - VALIDATE_SECTION_GRANDPARENT: same as -2 + * Example: + * @code + * function foo_form() { + * $form = array( + * '#tree' => TRUE, + * 'title' => array( + * ... + * ), + * 'group' => array( + * 'item1' => array(...), + * 'item2' => array(...), + * 'group_button' => array( + * '#type' => 'submit', + * '#submit' => array('foo_form_submit_that_only_acts_on_this_group'), + * '#validate_sections' => array(VALIDATE_SECTION_PARENT), + * ), + * ), + * ... + * ); + * } + * @endcode + * + * To not perform any validation at all, #validate_sections can be set to an + * empty array. + * + * As a final caution, note that using #validate_sections doesn't ensure that + * each corresponding part of $form_state['values'] is 100% valid. It only + * ensures that the validation handlers for elements AT THAT LEVEL AND DEEPER + * have run. Sometimes forms are created with parent elements validating + * child elements and these are not run if the parent element is outside of the + * specified section. And the form-level validation handlers ARE NOT RUN. + * Therefore, be extremely careful when using #validate_sections. It's great for + * buttons that are relatively harmless (like an "add another item" button). You + * should really know what you're doing if you're using it on a button that has + * a submit handler that saves data to the database, sends an email, or does + * anything else where input validation is critical. + * + * @param $element + * The element whose #parents will be checked to determine if this element's + * validation should be skipped. + * @param $form_state + * A keyed array containing the current state of the form. The + * 'clicked_button' property is checked to determine which elements should be + * validated. + * + * @return + * Boolean TRUE if the element should not be validated. FALSE if the element + * should be validated. + * + * @todo D8: This should live in _form_builder_handle_input_element(), because + * ideally, we want to skip not just validation, but all input processing for + * unrelated elements. However, we currently cannot skip input processing, + * because node and field validate/submit handlers cast $form_state['values'] + * into an object and presume that doing so results in an object containing + * most properties of an entity. + * + * @todo Move most of the above documentation to somewhere where developers + * will find it: not buried in a Forms API internal function. + */ +function _form_validate_skip($element, $form_state) { + // Check whether the pressed button defines #validate_sections to limit + // validation to elements within those sections. We prevent insecure execution + // of form-level submit handlers by not supporting limited validation unless a + // button-level submit handler is defined. + if (!(isset($form_state['clicked_button']['#validate_sections']) && isset($form_state['clicked_button']['#submit']))) { + return FALSE; + } + // The element is skipped only if it is not within any of the specified + // sections. + $skip = TRUE; + foreach ($form_state['clicked_button']['#validate_sections'] as $section) { + // $section can be a negative number, such as VALIDATE_SECTION_PARENT (-1), + // VALIDATE_SECTION_GRANDPARENT (-2), -3, etc. This indicates how far up + // the ancestor chain from the button itself to go to find the section that + // needs validation. Conveniently, this maps exactly to what array_slice() + // does. The way array_slice() works, if $section is a positive number, we + // would get back a section that many steps down from the root form. There + // might not be a good use-case for setting a positive number, but there's + // no reason to disallow it. + if (is_numeric($section)) { + $section = array_slice($form_state['clicked_button']['#parents'], 0, $section); + } + // If section is neither numeric (which gets converted to an array above), + // nor an array, the developer is not using the API correctly. Perhaps an + // error should be triggered. + if (is_array($section)) { + // If the element is the section itself or a descendant, then it needs + // validation. + if (count($element['#parents']) >= count($section) && array_slice($element['#parents'], 0, count($section)) === $section) { + $skip = FALSE; + break; + } + } + } + + return $skip; +} + +/** * A helper function used to execute custom validation and submission * handlers for a given form. Button-specific handlers are checked * first. If none exist, the function falls back to form-level handlers. @@ -969,7 +1164,13 @@ function form_execute_handlers($type, &$ } // Otherwise, check for a form-level handler. elseif (isset($form['#' . $type])) { - $handlers = $form['#' . $type]; + // Check if form-level validation handlers should be skipped. + if ($type == 'validate' && _form_validate_skip($form, $form_state)) { + $handlers = array(); + } + else { + $handlers = $form['#' . $type]; + } } else { $handlers = array(); Index: modules/field/field.form.inc =================================================================== RCS file: /cvs/drupal/drupal/modules/field/field.form.inc,v retrieving revision 1.38 diff -u -p -r1.38 field.form.inc --- modules/field/field.form.inc 21 Dec 2009 13:47:32 -0000 1.38 +++ modules/field/field.form.inc 28 Dec 2009 08:53:04 -0000 @@ -214,7 +214,12 @@ function field_multiple_value_form($fiel '#name' => $field_name . '_add_more', '#value' => t('Add another item'), '#attributes' => array('class' => array('field-add-more-submit')), - // Submit callback for disabled JavaScript. + // Note that the parent of this button is the language subkey of the + // field element. That should be all that needs validation during an + // "add more" operation, but if that changes and the entire field + // element needs validation, this should be set to + // VALIDATE_SECTION_GRANDPARENT. + '#validate_sections' => array(VALIDATE_SECTION_PARENT), '#submit' => array('field_add_more_submit'), '#ajax' => array( 'callback' => 'field_add_more_js', Index: modules/poll/poll.module =================================================================== RCS file: /cvs/drupal/drupal/modules/poll/poll.module,v retrieving revision 1.330 diff -u -p -r1.330 poll.module --- modules/poll/poll.module 26 Dec 2009 16:50:09 -0000 1.330 +++ modules/poll/poll.module 28 Dec 2009 08:53:04 -0000 @@ -273,7 +273,13 @@ function poll_form($node, &$form_state) '#value' => t('More choices'), '#description' => t("If the amount of boxes above isn't enough, click here to add more choices."), '#weight' => 1, - '#submit' => array('poll_more_choices_submit'), // If no javascript action. + // Normally, #validate_sections is set to array(VALIDATE_SECTION_PARENT), + // but that would result in the entire form being validated, because + // the container this button is in, 'choice_wrapper', has #tree set to + // FALSE, so the parent validation section of the button is the entire form. + // All that's really needed is for the 'choice' section to be valildated. + '#validate_sections' => array(array('choice')), + '#submit' => array('poll_more_choices_submit'), '#ajax' => array( 'callback' => 'poll_choice_js', 'wrapper' => 'poll-choices', Index: modules/simpletest/tests/form.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/form.test,v retrieving revision 1.31 diff -u -p -r1.31 form.test --- modules/simpletest/tests/form.test 17 Dec 2009 17:18:03 -0000 1.31 +++ modules/simpletest/tests/form.test 28 Dec 2009 08:53:04 -0000 @@ -231,6 +231,46 @@ class FormValidationTestCase extends Dru $this->assertNoFieldByName('name', t('Form element was hidden.')); $this->assertText('Name value: element_validate_access', t('Value for inaccessible form element exists.')); } + + /** + * Tests partial form validation using #validate_sections. + */ + function testValidatePartial() { + $this->drupalGet('form-test/validate-partial'); + + // Add one 'field' value without a value for required 'title' element. + $edit = array( + 'field[new]' => 'foo', + ); + $this->drupalPost(NULL, $edit, 'Add'); + $this->assertText('Form element validation handler for field triggered.'); + $this->assertFieldByName('field[0]', $edit['field[new]'], t('First field element contains submitted value.')); + $this->assertNoText(t('!name field is required.', array('!name' => 'Title'))); + $this->assertNoText('Form element validation handler for element triggered.'); + $this->assertNoText('Form submit handler triggered.'); + + // Try to submit the form without posting a title, but also not altering the + // default value for 'element' to trigger the element validation handler. + $this->drupalPost(NULL, array(), 'Submit'); + $this->assertText(t('!name field is required.', array('!name' => 'Title'))); + $this->assertText('Form element validation handler for element triggered.'); + $this->assertNoText('Form submit handler triggered.'); + + // Try again without posting a title. + $edit = array( + 'element' => 'bar', + ); + $this->drupalPost(NULL, $edit, 'Submit'); + $this->assertText(t('!name field is required.', array('!name' => 'Title'))); + $this->assertNoText('Form submit handler triggered.'); + + // Now do a valid post and verify invocation of submit handler. + $edit = array( + 'title' => 'beer', + ); + $this->drupalPost(NULL, $edit, 'Submit'); + $this->assertText('Form submit handler triggered.'); + } } /** Index: modules/simpletest/tests/form_test.module =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/form_test.module,v retrieving revision 1.24 diff -u -p -r1.24 form_test.module --- modules/simpletest/tests/form_test.module 17 Dec 2009 17:18:03 -0000 1.24 +++ modules/simpletest/tests/form_test.module 28 Dec 2009 08:53:04 -0000 @@ -17,6 +17,12 @@ function form_test_menu() { 'access arguments' => array('access content'), 'type' => MENU_CALLBACK, ); + $items['form-test/validate-partial'] = array( + 'title' => 'Partial form validation using #validate_sections test', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('form_test_validate_partial_form'), + 'access arguments' => array('access content'), + ); $items['form_test/tableselect/multiple-true'] = array( 'title' => 'Tableselect checkboxes test', @@ -204,6 +210,93 @@ function form_test_validate_form_validat } /** + * Form builder for testing partial validation using #validate_sections. + */ +function form_test_validate_partial_form($form, &$form_state) { + $form['#tree'] = TRUE; + + $form['title'] = array( + '#type' => 'textfield', + '#title' => 'Title', + '#required' => TRUE, + ); + $form['element'] = array( + '#type' => 'textfield', + '#title' => 'Element', + '#default_value' => 'ELEMENT', + '#element_validate' => array('form_test_validate_partial_form_element_validate'), + ); + + $form['field'] = array( + '#type' => 'fieldset', + '#title' => 'Field', + ); + if (empty($form_state['storage']['field'])) { + $form_state['storage']['field'] = array(); + } + // Always add an empty field the user can submit. + $form_state['storage']['field']['new'] = ''; + $weight = 0; + foreach ($form_state['storage']['field'] as $delta => $value) { + $form['field'][$delta] = array( + '#type' => 'textfield', + '#title' => 'Field ' . $delta, + '#default_value' => $value, + '#element_validate' => array('form_test_validate_partial_form_field_validate'), + '#weight' => $weight++, + ); + } + $form['field']['new']['#weight'] = 999; + $form['field']['submit'] = array( + '#type' => 'submit', + '#value' => 'Add', + '#validate_sections' => array(VALIDATE_SECTION_PARENT), + '#submit' => array('form_test_validate_partial_form_field_submit'), + '#weight' => 1000, + ); + + $form['submit'] = array('#type' => 'submit', '#value' => 'Submit'); + + return $form; +} + +/** + * Form element validation handler for 'element' in form_test_validate_partial_form(). + */ +function form_test_validate_partial_form_element_validate($element, &$form_state) { + if (empty($form_state['values']['element']) || $form_state['values']['element'] == $element['#default_value']) { + form_error($element, 'Form element validation handler for element triggered.'); + } +} + +/** + * Form element validation handler for 'field' in form_test_validate_partial_form(). + */ +function form_test_validate_partial_form_field_validate($element, &$form_state) { + drupal_set_message('Form element validation handler for field triggered.'); +} + +/** + * Form element submit handler for form_test_validate_partial_form(). + */ +function form_test_validate_partial_form_field_submit($form, &$form_state) { + // Store the new value and clear out the 'new' field. + $form_state['storage']['field'][] = $form_state['values']['field']['new']; + unset($form_state['input']['field']['new']); + // @todo The form constructor should be able to define this, but currently + // cannot. @see http://drupal.org/node/648170 + $form_state['rebuild'] = TRUE; + $form_state['cache'] = TRUE; +} + +/** + * Form submit handler for form_test_validate_partial_form(). + */ +function form_test_validate_partial_form_submit($form, &$form_state) { + drupal_set_message('Form submit handler triggered.'); +} + +/** * Create a header and options array. Helper function for callbacks. */ function _form_test_tableselect_get_data() {