diff --git a/commerce_addressbook.module b/commerce_addressbook.module index 5b55699..22a0b61 100644 --- a/commerce_addressbook.module +++ b/commerce_addressbook.module @@ -294,7 +294,7 @@ function commerce_addressbook_commerce_checkout_pane_info_alter(&$panes) { // which have the addressbook enabled. if (!empty($pane['callbacks']['checkout_form'])) { // We're free to insert our own info. - $pane['commerce_addressbook']['overridden_form'] = $panes[$pane_id]['callbacks']['checkout_form']; + $pane['commerce_addressbook']['overridden_form'] = $pane['callbacks']['checkout_form']; } $pane['callbacks']['checkout_form'] = 'commerce_addressbook_profile_checkout_form'; } @@ -312,9 +312,9 @@ function commerce_addressbook_profile_checkout_form($form, &$form_state, $checko $profile_id = NULL; if ($field_name = variable_get('commerce_' . $checkout_pane['pane_id'] . '_field', '')) { // Try the related customer profile reference field... - $wrapper = entity_metadata_wrapper('commerce_order', $order); - if (isset($wrapper->{$field_name})) { - $profile_id = $wrapper->{$field_name}->raw(); + $order_wrapper = entity_metadata_wrapper('commerce_order', $order); + if (isset($order_wrapper->{$field_name})) { + $profile_id = $order_wrapper->{$field_name}->raw(); } } else { @@ -324,16 +324,21 @@ function commerce_addressbook_profile_checkout_form($form, &$form_state, $checko } } - if (empty($profile_id)) { - // Set default profile into the order, before the commerce_customer module - // constructs its pane form. (The profile type is always part of the - // pane_id with 'customer_profile_' removed; see commerce_customer.module.) + // If not filled, set default profile into the order, before the + // commerce_customer module constructs its pane form and automatically + // populates the profile fields. (The profile type is always part of the + // pane_id with 'customer_profile_' removed; see commerce_customer.module.) + // An 'addressbook' value of 0 is our internal sign to skip doing this: + if (empty($profile_id) + && (!isset($form_state['values'][$checkout_pane['pane_id']]['addressbook']) + || $form_state['values'][$checkout_pane['pane_id']]['addressbook'] !== 0) + ) { $profile_id = commerce_addressbook_get_default_profile_id($user->uid, substr($checkout_pane['pane_id'], 17)); if ($profile_id) { // Set in the profile reference field or the order's data array, as above. if ($field_name) { - $wrapper->{$field_name} = $profile_id; + $order_wrapper->{$field_name} = $profile_id; } else { $order->data['profiles'][$checkout_pane['pane_id']] = $profile_id; @@ -375,36 +380,18 @@ function commerce_addressbook_form_alter(&$form, &$form_state, $form_id) { $checkout_page_id = substr($form_id, 23); // Find all panes for our current checkout page. foreach (commerce_checkout_panes(array('enabled' => TRUE, 'page' => $checkout_page_id)) as $pane_id => $checkout_pane) { - // If this pane is a customer profile based pane build a list of previous - // profiles from which to pick that are of the same bundle. + // If this pane is a customer profile based pane:, + // - build a select list of previous profiles (of the same bundle); + // - if 'copy profile' is enabled, check if the checkbox state is ok. if (substr($pane_id, 0, 17) == 'customer_profile_' && isset($form[$pane_id]) && variable_get('commerce_' . $pane_id . '_addressbook', FALSE)) { - $field = field_info_field(variable_get('commerce_' . $pane_id . '_field', '')); - $profiles = commerce_customer_profile_load_multiple(array(), array('type' => $field['settings']['profile_type'], 'uid' => $user->uid, 'status' => TRUE)); + $profiles = commerce_customer_profile_load_multiple(array(), array('type' => substr($pane_id, 17), 'uid' => $user->uid, 'status' => TRUE)); if ($profiles) { - // Get the profile ID which will be selected by default: - // - the ID submitted by a form; - // - or the one present in the order. (If the order had no saved - // profile, the default profile is set in the order already.) - if (!empty($form_state['values'][$pane_id]['addressbook'])) { - $default_value = $form_state['values'][$pane_id]['addressbook']; - } - else { - $order_wrapper = entity_metadata_wrapper('commerce_order', $form_state['order']); - if ($field_name = variable_get('commerce_' . $pane_id . '_field', '')) { - // Try the related customer profile reference field... - if (isset($order_wrapper->{$field_name})) { - $default_value = $order_wrapper->{$field_name}->raw(); - } - } - else { - // Or try the association stored in the order's data array. - if (!empty($form_state['order']->data['profiles'][$pane_id])) { - $default_value = $form_state['order']->data['profiles'][$pane_id]; - } - } - } - + // Get the profile ID which will be selected by default. The profile + // is already stored in the form, and up to date. (After an AJAX + // request from the addressbook/copy element, it's been updated.) + $profile = $form[$pane_id]['customer_profile']['#value']; + $default_value = $profile->profile_id; // Check if the default profile is in the enabled profiles array. if (isset($default_value)) { if (!isset($profiles[$default_value])) { @@ -418,37 +405,71 @@ function commerce_addressbook_form_alter(&$form, &$form_state, $form_id) { $default_value = 'none'; } } - - // Make sure our profile type still exists.. - if (!empty($form[$pane_id]['commerce_customer_profile_copy'])) { - if (($source_profile_type_name = variable_get('commerce_' . $pane_id . '_profile_copy_source', NULL)) - && $source_profile_type = commerce_customer_profile_type_load($source_profile_type_name)) { - // Disable the profile copy checkbox. - $form[$pane_id]['commerce_customer_profile_copy']['#default_value'] = FALSE; - $form[$pane_id]['commerce_customer_profile_copy']['#access'] = FALSE; - // Loop over source profile fields and enable previously disabled - // fields. - foreach (field_info_instances('commerce_customer_profile', $source_profile_type['type']) as $field_name => $field) { - if (!empty($form[$pane_id][$field_name])){ - $langcode = $form[$pane_id][$field_name]['#language']; - $form[$pane_id][$field_name][$langcode]['#access'] = TRUE; - foreach (element_children($form[$pane_id][$field_name][$langcode]) as $key) { - $form[$pane_id][$field_name][$langcode][$key]['#access'] = TRUE; - // Disable the editing of the profile if the user doesn't - // have the right to edit customer profiles. - if (!commerce_customer_profile_access('update', $profiles[$default_value])) { - $form[$pane_id][$field_name][$langcode][$key]['#disabled'] = TRUE; - } - } - } - } - } - } } else { $default_value = 'none'; } + // Check if customer profile copying is enabled (and from where). + $extra_description = ''; + if (!empty($form[$pane_id]['commerce_customer_profile_copy']) + && variable_get('commerce_' . $checkout_pane['pane_id'] . '_profile_copy', FALSE) + && ($source_type = variable_get('commerce_' . $pane_id . '_profile_copy_source', ''))) { + + $source_pane_id = 'customer_profile_' . $source_type; + + // Set extra validation callback for the 'profile copy' element that + // will update the target profile. Prepend it, so it runs before the + // regular callback saves the order. + array_unshift($form[$pane_id]['commerce_customer_profile_copy']['#element_validate'], 'commerce_addressbook_profile_copy_validate'); + + // Set extra after_build to catch $form_state['values']. + $form += array('#after_build' => array()); + $form['#after_build'][] = 'commerce_addressbook_copy_after_build'; + + // One case where form input/value can differ from #default_value: + // form reload after a validation error. + $copy = isset($form_state['values'][$pane_id]['commerce_customer_profile_copy']) + ? $form_state['values'][$pane_id]['commerce_customer_profile_copy'] + : $form[$pane_id]['commerce_customer_profile_copy']['#default_value']; + + // The checkbox' original #default_value is set according to + // a property in the order data, or the configured default for this + // pane. In some cases, we want to compare the two profiles' data + // and derive the checkbox state from those. + // (This could be done in commerce_customer, but there an order's + // profile data is only set after explicit user action, so having + // the contents of profiles influence the checkbox value is not a + // real UX improvement for Commerce core. It is for us, because we + // set default values for profiles.) + // We check profiles: + // - on 'non-submit' requests (we compare the addressbook defaults); + // - in AJAX requests from an element on the source pane, IF + // the 'copy profile' checkbox is FALSE. (If TRUE, we want the + // changes to be copied to the target profile. If the address + // select list is the triggering element, we just changed the + // target profile + element in the validate callback.) + // - in AJAX requests from anywhere else but the source pane (other + // modules may have influenced profiles too), EXCEPT for a + // 'copy profile' checkbox. + $triggering_element_path = array(''); + if (isset($form_state['triggering_element']['#parents'])) { + $triggering_element_path = $form_state['triggering_element']['#parents']; + } + + if (end($triggering_element_path) !== 'commerce_customer_profile_copy' + && (!$copy || $triggering_element_path[0] !== $source_pane_id)) { + module_load_include('inc', 'commerce_addressbook', 'includes/commerce_addressbook.form'); + $copy = _commerce_addressbook_check_copy_profile($form, $form_state, $pane_id, $source_type); + } + + if ($copy) { + // Insert a little extra help text about conflicts between + // the 'addressbook' and 'copy' elements. + $extra_description = ' ' . t('This will negate the above checkbox value.'); + } + } + // Prepare the options. $options = array(); foreach ($profiles as $id => $profile) { @@ -459,11 +480,12 @@ function commerce_addressbook_form_alter(&$form, &$form_state, $form_id) { $form[$pane_id]['#prefix'] = '
'; $form[$pane_id]['#suffix'] = '
'; + // Position the select list below the profile-copy checkbox. $form[$pane_id]['addressbook'] = array( '#addressbook' => TRUE, '#type' => 'select', '#title' => t('Addresses on File'), - '#description' => t('You may select a pre-existing address on file.'), + '#description' => t('You may select a pre-existing address on file.') . $extra_description, '#options' => $options, '#empty_option' => t('-- Choose --'), '#empty_value' => 'none', @@ -472,7 +494,7 @@ function commerce_addressbook_form_alter(&$form, &$form_state, $form_id) { 'wrapper' => strtr($pane_id, '_', '-') . '-ajax-wrapper', ), '#element_validate' => array('commerce_addressbook_saved_addresses_validate'), - '#weight' => -100, + '#weight' => -29, '#default_value' => $default_value, ); } @@ -480,7 +502,7 @@ function commerce_addressbook_form_alter(&$form, &$form_state, $form_id) { } } - if ($form_id == 'commerce_checkout_pane_settings_form' && substr($form['checkout_pane']['#value']['pane_id'], 0, 16) == 'customer_profile') { + if ($form_id == 'commerce_checkout_pane_settings_form' && substr($form['checkout_pane']['#value']['pane_id'], 0, 17) == 'customer_profile_') { $form['settings']['commerce_' . $form['checkout_pane']['#value']['pane_id'] . '_addressbook'] = array( '#type' => 'checkbox', '#title' => t('Enable the Address Book'), @@ -503,6 +525,22 @@ function commerce_addressbook_form_alter(&$form, &$form_state, $form_id) { } /** + * after_build callback for a checkout form with addressbook elements and a + * 'copy profile' checkbox on it. + */ +function commerce_addressbook_copy_after_build($form, &$form_state) { + // Preserve $form_state['values'] built from input values, so + // _commerce_addressbook_merge_form_input() can read them when the form gets + // rebuilt, even if the values get cleared out upon form validation. + // (That's what e.g. an AJAX callback from an addressfield country list does.) + if (!empty($form_state['input'])) { + $form_state['rebuild_info']['addressbook_values'] = $form_state['values']; + } + + return $form; +} + +/** * Ajax callback for replacing the appropriate commerce customer checkout pane. */ function commerce_addressbook_checkout_form_callback($form, $form_state) { @@ -511,6 +549,14 @@ function commerce_addressbook_checkout_form_callback($form, $form_state) { // Add replace element as default AJAX command. $commands = array(); $commands[] = ajax_command_replace(NULL, drupal_render($form[$pane_id])); + // Add extra panes where the addressbook element was updated too; check that + // we don't re-render the same pane. (At the moment only when JS/AJAX callback + // is disabled, duplicate $pane_ids can be set.) + if (!empty($form_state['rebuild_info']['addressbook'])) { + foreach (array_unique($form_state['rebuild_info']['addressbook']) as $pane_id) { + $commands[] = ajax_command_replace('#' . strtr($pane_id, '_', '-') . '-ajax-wrapper', drupal_render($form[$pane_id])); + } + } // Allow other modules to add arbitrary AJAX commands on the refresh. drupal_alter('commerce_addressbook_callback', $commands, $form, $form_state); @@ -522,10 +568,8 @@ function commerce_addressbook_checkout_form_callback($form, $form_state) { * Element validate callback: processes input of the address select list. */ function commerce_addressbook_saved_addresses_validate($element, &$form_state, $form) { - if (in_array('addressbook', $form_state['triggering_element']['#parents']) && $form_state['triggering_element']['#id'] == $element['#id']) { + if (in_array('addressbook', $form_state['triggering_element']['#parents'], TRUE) && $form_state['triggering_element']['#id'] == $element['#id']) { $pane_id = $element['#parents'][0]; - $field_name = variable_get('commerce_' . $pane_id . '_field', ''); - $order_wrapper = entity_metadata_wrapper('commerce_order', $form_state['order']); if (is_numeric($element['#value'])) { global $user; $profile = commerce_customer_profile_load($element['#value']); @@ -534,24 +578,46 @@ function commerce_addressbook_saved_addresses_validate($element, &$form_state, $ drupal_set_message(t('You must own the profile you are choosing.'), 'error'); return; } - // If we detect a change in the element's value, and the customer profile - // reference isn't already set to the specified value... - if ($order_wrapper->{$field_name}->raw() != $element['#value']) { - // Update the order based on the value and rebuild the form. - if ($element['#value'] == 0) { - $order_wrapper->{$field_name} = NULL; - } - else { - $order_wrapper->{$field_name} = $element['#value']; - } - } } - else { - $order_wrapper->{$field_name} = NULL; + module_load_include('inc', 'commerce_addressbook', 'includes/commerce_addressbook.form'); + _commerce_addressbook_validate_change_profile($element['#value'], $pane_id, $form_state, $form); + } +} + +/** + * Element validate callback: processes input of the copy checkbox. + */ +function commerce_addressbook_profile_copy_validate($element, &$form_state, $form) { + $triggering_element = end($form_state['triggering_element']['#parents']); + + // Checkbox: On - invoked for the corresponding trigger element, or the + // "continue" checkout form button. + if ((($triggering_element == 'commerce_customer_profile_copy' && $form_state['triggering_element']['#id'] == $element['#id']) || $triggering_element == 'continue') && !empty($element['#value'])) { + $pane_id = $element['#parents'][0]; + + // Get values for source pane. + if (variable_get('commerce_' . $pane_id . '_profile_copy', FALSE) + && ($source_type = variable_get('commerce_' . $pane_id . '_profile_copy_source', ''))) { + + // Find the target profile ID matching our (source) profile, and change + // it in the target addressfield element. + module_load_include('inc', 'commerce_addressbook', 'includes/commerce_addressbook.form'); + $profile_id = _commerce_addressbook_profile_find_target($form, $form_state, $pane_id, 'customer_profile_' . $source_type); + _commerce_addressbook_validate_change_profile($profile_id, $pane_id, $form_state, $form); + + // If the profile changed, the outdated/unused pane values are now + // cleared. On an AJAX request invoked by the checkbox element, the form + // rebuild will re-fill the profile, but on regular submit we should + // do it ourselves so the regular pane-validate function doesn't choke. + // (Note this never happens - unless the checkbox' #ajax was removed.) + if (!isset($profile_id)) { + $form_state['values'][$pane_id]['customer_profile'] = commerce_customer_profile_new(substr($pane_id, 17), $form_state['order']->uid); + } + elseif (!isset($form_state['values'][$pane_id]['customer_profile']->profile_id) + || $form_state['values'][$pane_id]['customer_profile']->profile_id != $profile_id) { + $form_state['values'][$pane_id]['customer_profile'] = commerce_customer_profile_load($profile_id); + } } - unset($form_state['input'][$pane_id]); - $element_key = $form[$pane_id]['commerce_customer_address'][$form[$pane_id]['commerce_customer_address']['#language']][0]['element_key']['#value']; - unset($form_state['addressfield'][$element_key]); } } diff --git a/includes/commerce_addressbook.form.inc b/includes/commerce_addressbook.form.inc new file mode 100644 index 0000000..b22c026 --- /dev/null +++ b/includes/commerce_addressbook.form.inc @@ -0,0 +1,523 @@ + 'ajax', '#commands' => array()); + } + else { + // Get result from the original callback. + $callback = $form_state['rebuild_info']['addressbook_original_ajax']; + $ajax_structure = $callback($form, $form_state); + } + + if (isset($ajax_structure['#commands'])) { + // Add extra panes where the 'copy' checkbox was updated too. + if (!empty($form_state['rebuild_info']['addressbook'])) { + foreach ($form_state['rebuild_info']['addressbook'] as $pane_id) { + $ajax_structure['#commands'][] = ajax_command_replace('#' . strtr($pane_id, '_', '-') . '-ajax-wrapper', drupal_render($form[$pane_id])); + } + } + } + return $ajax_structure; + } +} + +/** + * Returns a profile object for a pane form. This could be a new unsaved profile + * or NULL if not found. + * @param array $form + * @param array $form_state + * @param string $profile_type + * Type of profile (like 'billing'); it is not assumed that a corresponding + * checkout pane must exist on $form. + * @param bool $form_value_ok + * Set to FALSE if the 'customer_profile' #value in $form could be outdated. + * (i.e. when in a validate callback from the address select list.) + * + * @return object|null + */ +function _commerce_addressbook_get_profile(&$form, &$form_state, $profile_type, $form_value_ok = TRUE) { + $pane_id = 'customer_profile_' . $profile_type; + $profile = NULL; + + // First try to get the entity as set in its pane form definition. + // (This could be a new unsaved profile.) + if ($form_value_ok && isset($form[$pane_id]['customer_profile']['#value'])) { + $profile = $form[$pane_id]['customer_profile']['#value']; + } + // It's possible that there is no pane for the source profile on this checkout + // page, so try to load a profile if not found. + elseif ($source_ref_field_name = variable_get('commerce_' . $pane_id . '_field', '')) { + // Try the related customer profile reference field... + $order_wrapper = entity_metadata_wrapper('commerce_order', $form_state['order']); + if (isset($order_wrapper->{$source_ref_field_name})) { + $profile = $order_wrapper->{$source_ref_field_name}->value(); + } + } + else { + // Or try the association stored in the order's data array. + if (!empty($form_state['order']->data['profiles'][$profile_type])) { + $profile = commerce_customer_profile_load($form_state['order']->data['profiles'][$profile_type]); + } + } + + return $profile; +} + +/** + * Helper function for form_alter; checks what value we want the 'copy' checkbox + * to have. If that differs from the actual value: changes it, adjusts other + * properties and enables/disables profile fields if needed. + * + * Returns boolean for the (new) checkbox value; NULL if no check was done. + */ +function _commerce_addressbook_check_copy_profile(&$form, &$form_state, $pane_id, $source_type) { + $source_pane_id = 'customer_profile_' . $source_type; + + // Get main (target) profile. (Just as in caller.) + $profile = $form[$pane_id]['customer_profile']['#value']; + // Get source profile. (We don't know if this #value is on the form.) + $source_profile = _commerce_addressbook_get_profile($form, $form_state, $source_type); + + // Determine what the value of the 'copy' checkbox should be. + + // Skip the comparison if both profiles are new. (Not if one of them is new; + // if you have two 'equal' addresses select from the addressbook and you + // select the "none" option, the copy checkbox should be cleared.) + if (isset($source_profile) + && (!empty($profile->profile_id) || !empty($source_profile->profile_id))) { + + // If the 'copy' value we determined is different from the checkbox' + // default, take action. + + $copy = _commerce_addressbook_profiles_equal($profile, $source_profile, $form, $form_state, $pane_id, 'customer_profile_' . $source_type); + $default = isset($form_state['values'][$pane_id]['commerce_customer_profile_copy']) + ? $form_state['values'][$pane_id]['commerce_customer_profile_copy'] + : $form[$pane_id]['commerce_customer_profile_copy']['#default_value']; + + if ($copy == empty($default)) { + + $form[$pane_id]['commerce_customer_profile_copy']['#default_value'] = $copy; + // Unset the input value to + // - always make #default_value displayed, if the form gets rebuilt. + // on AJAX. ('input' was already emptied for a request triggered by + // the checkbox, but not for an address select list on the source pane.) + // - prevent a copying action, if it is set. Note: + // * We choose not to do the reverse; never set any 'copy' value if it's + // currently unset. We don't want to risk triggering a copying action + // without having shown this to the website user. + // * This is just a precaution. There is no regular situation where + // changing $form_state['input'] has any effect, because + // - regular form submits don't execute this code (form is cached); + // - AJAX requests will not process input after this; only rerender. + unset($form_state['input'][$pane_id]['commerce_customer_profile_copy']); + + // Sometimes (specifically, on every change not caused by an address + // select list) we choose to leave the profile fields enabled, when + // turning on the checkbox, because hiding everything at the moment you do + // something unrelated seems bad UX. + // (So effectively, then the 'copy' checkbox becomes just an indicator + // whether a profile is equal.) + $skip_hide = FALSE; + if ($copy && !empty($form_state['input']) && !in_array('addressbook', $form_state['triggering_element']['#parents'], TRUE)) { + // Not hiding elements means there can be input conflicts: when 'copy' + // is checked, all submitted profile fields will be discarded. + // Account for this situation by warning in the checkbox description. + // (NOTE: we should account for this situation anyway, if we want to + // support this module in non-JS environments. + // Finishing that support is left as a TODO.) + $form[$pane_id]['addressbook_warning'] = array( + '#markup' => t("WARNING: any changes made to the fields below will be discarded, unless the 'is the same' checkbox is unchecked."), + '#prefix' => '
', + '#suffix' => '
', + '#weight' => -100, + ); + $skip_hide = TRUE; + } + // Loop over source profile fields and enable/disable previously + // disabled/enabled fields, mimicking commerce_customer module behavior: + // - we take the source instances (not target) because that's what + // commerce_customer_profile_pane_checkout_form() does too. + // - we prefer taking $form_state['values'] over $source_profile for + // setting 'elements' because commerce_customer_profile_copy_fields() + // does that too - and there's a difference: if a field is empty, + // the submitted form value is (LANGUAGE => (0 => "")) while + // $source_profile->{$field_name} is an empty array. The first + // disables the destination field; the second won't. + $source = isset($form_state['values'][$source_pane_id]['customer_profile']) ? (object) $form_state['values'][$source_pane_id] : $source_profile; + foreach (field_info_instances('commerce_customer_profile', $source_type) as $field_name => $field_def) { + if (!empty($form[$pane_id][$field_name])){ + + // - Disable / enable fields, and + // - Note in the order that the element value is copied from the + // source; this makes commerce_customer module keep the element + // disabled on form rebuild. + // Code copied from commerce_customer_profile_copy_fields() & changed. + $field = array(); + if (isset($source->$field_name) && is_array($source->$field_name)) { + $field = $source->$field_name; + foreach ($field as $langcode => $items) { + + if (is_array($items)) { + foreach ($items as $delta => $item) { + // from commerce_customer_profile_pane_checkout_form(): + if (!$skip_hide && isset($form[$pane_id][$field_name][$langcode][$delta])) { + $form[$pane_id][$field_name][$langcode][$delta]['#access'] = !$copy; + } + + if ($copy) { + $form_state['order']->data['profile_copy'][$pane_id]['elements'][$field_name][$langcode][$delta] = TRUE; + } + } + } + elseif ($copy) { + $form_state['order']->data['profile_copy'][$pane_id]['elements'][$field_name][$langcode] = TRUE; + } + } + } + // commerce_customer_profile_pane_checkout_form() is inconsistent; if + // $copy == FALSE here, it has sometimes disabled field_name->und->0 + // and sometimes field_name->und. (The latter was done when no order + // 'copy' properties are set yet. So check for #access also when + // $items is an array. + $langcode = !empty($form[$pane_id][$field_name]['#language']) ? $form[$pane_id][$field_name]['#language'] : ''; + if ((!$field && !$skip_hide && isset($form[$pane_id][$field_name][$langcode])) + || isset($form[$pane_id][$field_name][$langcode]['#access'])) { + $form[$pane_id][$field_name][$langcode]['#access'] = !$copy; + } + } + } + + if (!$copy) { + // Unset values in the order that say which elements are copied. + unset($form_state['order']->data['profile_copy'][$pane_id]['elements']); + } + // Change status. (We keep status = FALSE in the order even when unsetting + // 'elements', just like commerce_customer does.) + $form_state['order']->data['profile_copy'][$pane_id]['status'] = $copy; + + // Save the order. (Since $form_state['order'] is not going to be used by + // the pane validation, we need to either make sure that the changed order + // properties are saved here, or re-apply the changes in the pane + // validate / submit callback. The latter is too much work.) + commerce_order_save($form_state['order']); + + if (isset($form_state['triggering_element']['#ajax'])) { + if (in_array('addressbook', $form_state['triggering_element']['#parents'], TRUE)) { + // The triggering element is the address select list. Only if it is + // on another pane, we need to instruct the render callback to also + // render this pane with the checkbox. + if ($form_state['triggering_element']['#parents'][0] !== $pane_id) { + $form_state += array('addressbook' => array()); + $form_state['rebuild_info']['addressbook'][] = $pane_id; + } + } + else { + // The triggering element is unknown. (Likely candidate: country.) + // We can't influence the AJAX wrapper div anymore, only the callback. + // Put a 'wrapper' around the current callback to also redraw our pane. + $path = array_merge($form_state['triggering_element']['#parents'], array('#ajax', 'callback')); + if (!isset($form_state['rebuild_info']['addressbook_original_callback'])) { + // Store original callback; change it to our wrapper. + $form_state['rebuild_info']['addressbook_original_ajax'] = + drupal_array_get_nested_value($form, $path); + drupal_array_set_nested_value($form, $path, 'commerce_addressbook_ajax_callback_wrapper'); + } + // If the triggering element is on this pane, then don't redraw it + // twice; 'cancel' the current one. + if ($form_state['triggering_element']['#parents'][0] == $pane_id) { + $form_state['rebuild_info']['addressbook_original_ajax_cancel'] = TRUE; + } + + // In addition, mark our pane for being redrawn. + $form_state['rebuild_info'] += array('addressbook' => array()); + $form_state['rebuild_info']['addressbook'][] = $pane_id; + } + } + } + return $copy; + } +} + +/** + * Returns boolean indicating whether a target/source profile have equal fields. + * (Two new profiles do not compare as 'equal'.) + */ +function _commerce_addressbook_profiles_equal($profile, $source_profile, $form, $form_state, $pane_id, $source_pane_id, $skip_dest_input = FALSE) { + // This function is split out from the form_alter only to make it shorter + // (and because the details of field extraction / comparison have nothing to + // do with surrounding functionality); it's not meant to be general. + + // If there's input, we are in an AJAX-triggered rebuild (or the form has + // errors): Form values for the profile fields are not validated or updated + // into the entity, but they are available. We want to compare the profiles + // with up to date field values. + // (The primary purpose of this function is just to check whether two selected + // / default profiles are equal. But in edge cases, it may be less confusing + // if the value of the checkbox / target profile -which will be set based + // on this- follows the current values on the form.) + // So, get those form values into the entity, unless earlier processing + // (from address select list?) has unset input for this pane. + if (!$skip_dest_input && isset($form_state['input'][$pane_id])) { + $profile = _commerce_addressbook_merge_form_input($profile, $form, $form_state, $pane_id); + } + if (isset($form_state['input'][$source_pane_id])) { + $source_profile = _commerce_addressbook_merge_form_input($source_profile, $form, $form_state, $source_pane_id); + } + + if (empty($profile->profile_id) || empty($source_profile->profile_id)) { + return FALSE; + } + + // Check if all fields in the 'target' profile are present and equal in the + // 'source' profile. (This code is built to match how + // commerce_customer_profile_copy_fields() works, but needed to be extended + // to accommodate submitted form values vs. profile fields.) + + // Loop over all the field instances that could be attached to this target. + foreach (field_info_instances('commerce_customer_profile', substr($pane_id, 17)) + as $field_name => $instance) { + $field = $source_profile->{$field_name}; + + // Loop over the source field value and see if its items are equal. + // Take into account that array( LANG => array() ) should count as empty. + if (empty($field) || (is_array($field) && !array_filter($field))) { + if (!empty($profile->{$field_name}) + && (!is_array($profile->{$field_name}) || array_filter($profile->{$field_name}))) { + return FALSE; + } + } + elseif (!is_array($field)) { + if (empty($profile->{$field_name}) || $profile->{$field_name} !== $field) { + return FALSE; + } + } + else { + foreach ($field as $langcode => $items) { + if (empty($items)) { + if (!empty($profile->{$field_name}[$langcode])) { + return FALSE; + } + } + else { + if (empty($profile->{$field_name}[$langcode])) { + return FALSE; + } + if (is_array($items) && is_array($profile->{$field_name}[$langcode])) { + if (array_map('_commerce_addressbook_filter_field_properties', $profile->{$field_name}[$langcode]) + !== array_map('_commerce_addressbook_filter_field_properties', $items)) { + return FALSE; + } + } + elseif ($profile->{$field_name}[$langcode] !== $items) { + return FALSE; + } + } + } + } + } + + return TRUE; +} + +/** + * Update a profile with $form_state values; only for comparison purposes, + * the profile is not meant to be used/stored after this. + */ +function _commerce_addressbook_merge_form_input($profile, $form, $form_state, $pane_id) { + global $user; + + $entity = isset($profile) ? clone $profile : + commerce_customer_profile_new(substr($pane_id, 17), $user->uid); + + // We saved the full input values (regardless whether they're correctly + // validated); if $form_state['values'] is empty, use these for comparison. + // Notes: + // - in an AJAX request from an address select list, these input values are + // outdated for the corresponding pane - but this function doesn't + // get called for that pane; + // - if $form_state['values'] is still filled, prefer these; validation may + // have changed them to be more accurate; + // - $form_state is not passed by reference. + if (!empty($form_state['rebuild_info']['addressbook_values'][$pane_id]) && empty($form_state['values'][$pane_id])) { + $form_state['values'][$pane_id] = $form_state['rebuild_info']['addressbook_values'][$pane_id]; + } + + // Now add $form_state['values'] to the entity. (Is not set in edge cases + // where form data is posted but the $form_state cache was emptied.) + if (isset($form_state['values'])) { + // Part of field_attach_form_validate(): + _field_invoke_default('extract_form_values', 'commerce_customer_profile', $entity, $form, $form_state); + try { + field_attach_validate('commerce_customer_profile', $entity); + + entity_form_submit_build_entity('commerce_customer_profile', $entity, $form[$pane_id], $form_state); + // This is necessary to populate field properties that may exist + // already in the other profile's database-loaded fields: + _field_invoke_multiple('load', 'commerce_customer_profile', array($entity->profile_id => $entity)); + $profile = $entity; + } + catch (Exception $e) { + // Just return $profile with no values updated. + } + } + + return $profile; +} + + +/** + * Callback for array_map(); removes unwanted properties from field items, + * so fields from 'submitted values' can be compared with fields loaded from + * a database. + */ +function _commerce_addressbook_filter_field_properties($item) { + if (is_array($item)) { + // Even after invoking hook_field_load (which populates extra properties in + // the 'submitted' values), there are still differences: + foreach ($item as $key => $value) { + // - db-loaded values can have properties set from default database fields + // in the SQL table which are not present in submitted values. + // (Example: ('format' => NULL) for almost all text fields.) + // Filter out NULLs. (Note this doesn't account for database fields with + // non-null default values, but we don't know of any.) + // - that means we should remove properties with value '' (not 0) too. + // db-loaded values of NULL often correspond to submitted values of ''. + if (!isset($value) || $value === '') { + unset($item[$key]); + } + } + // - submitted addressfield values have an "element_key" property which is + // used only for form element handling; not present in the db-loaded value + unset($item['element_key']); + ksort($item); + } + return $item; +} + +/** + * Helper function for element validate callbacks; if a profile ID has changed + * for a certain pane, change the appropriate values in $form_state to be + * consistent with the new ID & ready for a form rebuild. + */ +function _commerce_addressbook_validate_change_profile($new_profile_id, $pane_id, &$form_state, $form) { + + // Get existing field + if ($field_name = variable_get('commerce_' . $pane_id . '_field', '')) { + // Try the related customer profile reference field... + $order_wrapper = entity_metadata_wrapper('commerce_order', $form_state['order']); + if (isset($order_wrapper->{$field_name})) { + $profile_id = $order_wrapper->{$field_name}->raw(); + } + } + else { + // Or try the association stored in the order's data array. + if (!empty($form_state['order']->data['profiles'][$pane_id])) { + $profile_id = $form_state['order']->data['profiles'][$pane_id]; + } + } + + if ($new_profile_id === 'none') { + $new_profile_id = NULL; + } + if ($new_profile_id != $profile_id) { + + // Set changed value in the profile reference field or the order's data + // array, as above. This will take care of setting the right value in + // the form rebuild, if we're in an AJAX request. + if ($field_name) { + $order_wrapper->{$field_name} = $new_profile_id; + } + else { + $form_state['order']->data['profiles'][$pane_id] = $new_profile_id; + } + if (empty($new_profile_id)) { + // Signify to commerce_addressbook_profile_checkout_form() that it should + // leave the profile empty instead of filling the default profile. + // ($form_state['values'] is still present during the form build callback + // but will be lost and rebuilt shortly afterwards, so this is an OK way.) + $form_state['values'][$pane_id]['addressbook'] = 0; + } + + // Remove input fields for this customer profile, which is outdated now the + // selected profile itself has changed. (An AJAX request from the + // address select list overrides any input in other fields; the form rebuild + // will populate the fields from the new profile, set above.) + unset($form_state['input'][$pane_id]); + $element_key = $form[$pane_id]['commerce_customer_address'][$form[$pane_id]['commerce_customer_address']['#language']][0]['element_key']['#value']; + unset($form_state['addressfield'][$element_key]); + // If we're resetting an invisible 'target' addressfield, then probably + // addressfield_standard_country_validate() will still run after us and + // REpopulate $form_state['addressfield'] from the outdated 'values' (which + // causes the outdated address to reappear, now or when you untick the + // 'copy' checkbox). Work around this; just unset all outdated 'values'. + $form_state['values'][$pane_id] = array_intersect_key($form_state['values'][$pane_id], array('addressbook' => TRUE)); + + // Loop through all checkout panes on this form, and see if any panes + // copy their information from this one & have an address select list. + // If so, we also need to change that pane's profile value. + // Find all panes for our checkout page. + $checkout_page_id = substr($form['#form_id'], 23); + foreach (commerce_checkout_panes(array('enabled' => TRUE, 'page' => $checkout_page_id)) as $target_pane_id => $checkout_pane) { + // Is a (different) pane form? + if (isset($form[$target_pane_id]) && $target_pane_id != $pane_id + // ...which has addressbook functionality enabled? + && substr($target_pane_id, 0, 17) == 'customer_profile_' && variable_get('commerce_' . $target_pane_id . '_addressbook', FALSE) + // ...and has the current pane as a source? + && variable_get('commerce_' . $target_pane_id . '_profile_copy', FALSE) + && variable_get('commerce_' . $target_pane_id . '_profile_copy_source', '') == substr($pane_id, 17) + // ...and the 'copy' checkbox is ON? (form values are populated.) + && !empty($form_state['values'][$target_pane_id]['commerce_customer_profile_copy']) + ) { + $profile_id = _commerce_addressbook_profile_find_target($form, $form_state, $target_pane_id, $pane_id); + _commerce_addressbook_validate_change_profile($profile_id, $target_pane_id, $form_state, $form); + // Note that this pane should also be rerendered by the AJAX callback. + // We should use a form_state property that isn't cached; rebuild_info + // seems a good fit. + $form_state['rebuild_info'] += array('addressbook' => array()); + $form_state['rebuild_info']['addressbook'][] = $target_pane_id; + } + } + } +} + +/** + * Finds target profile matching our source input/profile, returns ID or NULL. + */ +function _commerce_addressbook_profile_find_target(&$form, &$form_state, $pane_id, $source_pane_id) { + global $user; + + $source_profile = _commerce_addressbook_get_profile($form, $form_state, substr($source_pane_id, 17), FALSE); + + $profiles = commerce_customer_profile_load_multiple(array(), array('type' => substr($pane_id, 17), 'uid' => $user->uid, 'status' => TRUE)); + foreach ($profiles as $id => $profile) { + // Compare $profile with our source profile; do not incorporate form input + // for the _target_ profile. + if (_commerce_addressbook_profiles_equal($profile, $source_profile, $form, $form_state, $pane_id, $source_pane_id, TRUE)) { + return $id; + } + } +}