=== modified file 'includes/form.inc' --- includes/form.inc 2009-12-17 21:59:31 +0000 +++ includes/form.inc 2009-12-24 19:41:22 +0000 @@ -897,7 +897,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 +935,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 +947,70 @@ function _form_validate(&$elements, &$fo } /** + * Check if an element's validation may be skipped. + * + * A form button may specify the #validate_parents property to limit form + * validation to form elements that share the same #parents with + * #validate_parents. Such buttons are required to also define custom #submit + * handlers, which will ONLY process form values for elements contained in + * #validate_parents. + * + * For example, when an "add another item" button for a multiple value field is + * clicked, we don't want a not yet filled out but required title to cause a + * validation error, preventing a new item from being added to the form. By + * setting that button's #validate_parents property to the #parents of the + * field, only elements within the field will be validated when the button is + * clicked. + * + * This will never skip properties that are enforced by HTML, such as #maxlength + * or #options. + * + * @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. + */ +function _form_validate_skip($element, $form_state) { + // Check whether the pressed button defines #validate_parents to limit + // validation to elements within those parents. 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_parents']) && isset($form_state['clicked_button']['#submit']))) { + return FALSE; + } + // Iterate over the #parents of $element and compare them with the + // #validate_parents defined by the pressed button. By setting + // #validate_parents, the button indicates that it only needs that portion of + // $form_state['values'] to be validated. If the element's #parents property + // makes the element's value reside in a different part of + // $form_state['values'], then validation for this element may be skipped. + $parents = $form_state['clicked_button']['#validate_parents']; + $skip = FALSE; + foreach ($parents as $key => $parent) { + if ($element['#parents'][$key] != $parent) { + $skip = TRUE; + 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. @@ -1304,6 +1368,11 @@ function _form_builder_handle_input_elem // those that execute full submit callbacks and those that only validate. $button_type = $element['#executes_submit_callback'] ? 'submit' : 'button'; $form_state['buttons'][$button_type][] = $element; + // If #validate_parents is set then make sure the element level validate + // handler is set too so the form level validate can not fire. + if (isset($element['#validate_parents']) && !isset($element['#validate'])) { + $element['#validate'] = array(); + } if (_form_button_was_clicked($element, $form_state)) { $form_state['submitted'] = $form_state['submitted'] || $element['#executes_submit_callback']; === modified file 'modules/field/field.form.inc' --- modules/field/field.form.inc 2009-12-21 13:47:31 +0000 +++ modules/field/field.form.inc 2009-12-24 19:42:26 +0000 @@ -214,6 +214,7 @@ function field_multiple_value_form($fiel '#name' => $field_name . '_add_more', '#value' => t('Add another item'), '#attributes' => array('class' => array('field-add-more-submit')), + '#validate_parents' => array($field_name), // Submit callback for disabled JavaScript. '#submit' => array('field_add_more_submit'), '#ajax' => array( === modified file 'modules/poll/poll.module' --- modules/poll/poll.module 2009-12-14 20:38:15 +0000 +++ modules/poll/poll.module 2009-12-24 19:37:46 +0000 @@ -273,7 +273,8 @@ 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. + '#validate_parents' => array('choice_wrapper'), + '#submit' => array('poll_more_choices_submit'), '#ajax' => array( 'callback' => 'poll_choice_js', 'wrapper' => 'poll-choices', === modified file 'modules/simpletest/tests/form.test' --- modules/simpletest/tests/form.test 2009-12-17 17:18:02 +0000 +++ modules/simpletest/tests/form.test 2009-12-24 19:36:01 +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_parents. + */ + function testValidateParents() { + $this->drupalGet('form-test/validate-parents'); + + // 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.'); + } } /** === modified file 'modules/simpletest/tests/form_test.module' --- modules/simpletest/tests/form_test.module 2009-12-17 17:18:02 +0000 +++ modules/simpletest/tests/form_test.module 2009-12-24 19:36:01 +0000 @@ -17,6 +17,12 @@ function form_test_menu() { 'access arguments' => array('access content'), 'type' => MENU_CALLBACK, ); + $items['form-test/validate-parents'] = array( + 'title' => '#validate_parents test', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('form_test_validate_parents_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 #validate_parents. + */ +function form_test_validate_parents_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_parents_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_parents_form_field_validate'), + '#weight' => $weight++, + ); + } + $form['field']['new']['#weight'] = 999; + $form['field']['submit'] = array( + '#type' => 'submit', + '#value' => 'Add', + '#validate_parents' => array('field'), + '#submit' => array('form_test_validate_parents_form_field_submit'), + '#weight' => 1000, + ); + + $form['submit'] = array('#type' => 'submit', '#value' => 'Submit'); + + return $form; +} + +/** + * Form element validation handler for 'element' in form_test_validate_parents_form(). + */ +function form_test_validate_parents_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_parents_form(). + */ +function form_test_validate_parents_form_field_validate($element, &$form_state) { + drupal_set_message('Form element validation handler for field triggered.'); +} + +/** + * Form element submit handler for form_test_validate_parents_form(). + */ +function form_test_validate_parents_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_parents_form(). + */ +function form_test_validate_parents_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() {