diff --git a/modules/order/src/Element/ProfileSelect.php b/modules/order/src/Element/ProfileSelect.php index d530915637..707a0974f0 100644 --- a/modules/order/src/Element/ProfileSelect.php +++ b/modules/order/src/Element/ProfileSelect.php @@ -2,11 +2,16 @@ namespace Drupal\commerce_order\Element; +use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\NestedArray; use Drupal\commerce\Element\CommerceElementTrait; use Drupal\Core\Entity\Entity\EntityFormDisplay; +use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element; use Drupal\Core\Render\Element\RenderElement; use Drupal\profile\Entity\ProfileInterface; +use Symfony\Component\HttpFoundation\Request; /** * Provides a form element for selecting a customer profile. @@ -18,6 +23,10 @@ * '#default_value' => $profile, * '#default_country' => 'FR', * '#available_countries' => ['US', 'FR'], + * '#profile_view_mode' => 'default', + * '#reuse_profile_label' => $this->t('My billing address is the same as my shipping address.'), + * '#reuse_profile_source' => 'commerce_shipping_get_shipping_profile', + * '#reuse_profile_default' => FALSE, * ]; * @endcode * To access the profile in validation or submission callbacks, use @@ -40,9 +49,20 @@ public function getInfo() { '#default_country' => NULL, // A list of country codes. If empty, all countries will be available. '#available_countries' => [], - + // The view mode to render existing profiles with. + '#profile_view_mode' => 'default', + // The label for the reuse profile checkbox. If empty, checkbox is hidden. + '#reuse_profile_label' => NULL, + // The function to call to return the profile to reuse. + '#reuse_profile_source' => NULL, + // Whether the reuse checkbox should be checked by default. + '#reuse_profile_default' => FALSE, // The profile entity operated on. Required. '#default_value' => NULL, + // The profile that to be submitted. Populated automatically. + '#profile' => NULL, + // The operation, 'view', 'add', or 'edit'. Usually populated automatically. + '#op' => NULL, '#process' => [ [$class, 'attachElementSubmit'], [$class, 'processForm'], @@ -86,15 +106,84 @@ public static function processForm(array $element, FormStateInterface $form_stat throw new \InvalidArgumentException('The commerce_profile_select #available_countries property must be an array.'); } - $element['#profile'] = $element['#default_value']; - $form_display = EntityFormDisplay::collectRenderDisplay($element['#profile'], 'default'); - $form_display->buildForm($element['#profile'], $element, $form_state); + // Assign a name if needed. + if (empty($element['#name'])) { + list($name) = explode('--', $element['#id']); + $element['#name'] = 'profile-select--' . $name; + } + + // Assign shared variables. + $called_class = get_called_class(); + $storage = $form_state->getStorage(); + $pane_storage = isset($storage['pane_' . $element['#name']]) + ? $storage['pane_' . $element['#name']] + : []; + $profile_selection = isset($pane_storage['profile_selection']) + ? $pane_storage['profile_selection'] + : NULL; + $reuse_profile = isset($pane_storage['reuse_profile']) + ? $pane_storage['reuse_profile'] + : $element['#reuse_profile_default']; + + // Define AJAX wrapper + $ajax_wrapper_id = Html::getUniqueId('profile-select-ajax-wrapper'); + $element['#prefix'] = '
'; + $element['#suffix'] = '
'; + + // Load authenticated user's profiles. + $profiles = []; + if ($element['#default_value']->getOwnerId() > 0) { + $profile_ids = \Drupal::service('entity.query') + ->get('profile') + ->condition('uid', $element['#default_value']->getOwnerId()) + ->condition('type', $element['#default_value']->bundle()) + ->condition('status', TRUE) + ->sort('profile_id', 'DESC') + ->execute(); + $profiles = \Drupal::entityTypeManager()->getStorage('profile')->loadMultiple($profile_ids); + } + + // Determine operation to perform. + if (isset($pane_storage['op'])) { + $element['#op'] = $pane_storage['op']; + } + elseif ($profile_selection) { + $element['#op'] = ($profile_selection == 'new_profile') ? 'add' : 'view'; + } + elseif (is_null($element['#op'])) { + $element['#op'] = (!empty($profiles)) ? 'view' : 'add'; + } + // If no account profiles returned but we're viewing one, edit it instead. + if ($element['#op'] === 'view' && empty($profiles)) { + $element['#op'] = 'edit'; + } + + // Determine default profile + $default_profile = $element['#default_value']; + // If user wants to create a new profile, do so. + if ($profile_selection) { + $default_profile = ($profile_selection == 'new_profile') + ? \Drupal::entityTypeManager()->getStorage('profile')->create([ + 'type' => $default_profile->bundle(), + 'uid' => $default_profile->getOwnerId(), + ]) + : $profiles[$profile_selection]; + } + elseif($element['#op'] == 'view' && !$default_profile->id()) { + $default_profile = reset($profiles); + } + $element['#default_value'] = $default_profile; + $element['#profile'] = $default_profile; + + // Set default address field options. + $form_display = EntityFormDisplay::collectRenderDisplay($default_profile, 'default'); + $form_display->buildForm($default_profile, $element, $form_state); if (!empty($element['address']['widget'][0])) { $widget_element = &$element['address']['widget'][0]; // Remove the details wrapper from the address widget. $widget_element['#type'] = 'container'; // Provide a default country. - if (!empty($element['#default_country']) && empty($widget_element['address']['#default_value']['country_code'])) { + if (!empty($element['#default_country']) && $element['#op'] === 'add') { $widget_element['address']['#default_value']['country_code'] = $element['#default_country']; } // Limit the available countries. @@ -103,6 +192,84 @@ public static function processForm(array $element, FormStateInterface $form_stat } } + // Show the profile reuse checkbox if enabled. + if ((!empty($element['#reuse_profile_label']) && !empty($element['#reuse_profile_source']))) { + $element['reuse_profile'] = [ + '#title' => $element['#reuse_profile_label'], + '#type' => 'checkbox', + '#weight' => -5, + '#default_value' => $reuse_profile, + '#ajax' => [ + 'callback' => [$called_class, 'profileAjax'], + 'wrapper' => $ajax_wrapper_id, + ], + '#element_validate' => [[$called_class, 'profileReuseValidate']] + ]; + } + + // Hide the profile fields if profile reuse checkbox is checked. + if ($reuse_profile) { + self::hideProfileFields($element, ['reuse_profile']); + } + else { + // Output a profile select element. + if ($element['#op'] != 'edit' && !empty($profiles)) { + $profile_options = []; + foreach ($profiles as $profile_option) { + $profile_options[$profile_option->id()] = $profile_option->label(); + } + $profile_options['new_profile'] = t('+ Enter a new profile'); + + $element['profile_selection'] = [ + '#title' => t('Select a profile'), + '#options' => $profile_options, + '#type' => 'select', + '#weight' => -5, + '#default_value' => $default_profile->id() ?: 'new_profile', + '#ajax' => [ + 'callback' => [$called_class, 'profileAjax'], + 'wrapper' => $ajax_wrapper_id, + ], + '#element_validate' => [[$called_class, 'profileSelectValidate']], + ]; + } + + if ($element['#op'] == 'view') { + $element['rendered_profile'] = [ + \Drupal::entityTypeManager() + ->getViewBuilder('profile') + ->view($default_profile, $element['#profile_view_mode']), + ]; + $element['edit_button'] = [ + '#type' => 'button', + '#name' => 'pane-' . $element['#name'] . '-edit', + '#value' => t('Edit'), + '#limit_validation_errors' => [], + '#ajax' => [ + 'callback' => [$called_class, 'profileAjax'], + 'wrapper' => $ajax_wrapper_id, + ], + '#element_validate' => [[$called_class, 'profileEditCancelValidate']], + ]; + self::hideProfileFields($element, ['edit_button', 'rendered_profile', 'profile_selection', 'reuse_profile']); + } + // Editing an existing profile. + elseif (!empty($profiles) && $element['#op'] == 'edit') { + $element['cancel_button'] = [ + '#type' => 'button', + '#name' => 'pane-' . $element['#name'] . '-cancel', + '#value' => t('Cancel and select profile'), + '#limit_validation_errors' => [], + '#ajax' => [ + 'callback' => [$called_class, 'profileAjax'], + 'wrapper' => $ajax_wrapper_id, + ], + '#element_validate' => [[$called_class, 'profileEditCancelValidate']], + '#weight' => 99, + ]; + } + } + return $element; } @@ -119,9 +286,17 @@ public static function processForm(array $element, FormStateInterface $form_stat * form, as a protection against buggy behavior. */ public static function validateForm(array &$element, FormStateInterface $form_state) { - $form_display = EntityFormDisplay::collectRenderDisplay($element['#profile'], 'default'); - $form_display->extractFormValues($element['#profile'], $element, $form_state); - $form_display->validateFormValues($element['#profile'], $element, $form_state); + $triggering_parents = $form_state->getTriggeringElement()['#parents']; + $last_parent = array_pop($triggering_parents); + $storage = $form_state->getStorage(); + $pane_storage = isset($storage['pane_' . $element['#name']]) + ? $storage['pane_' . $element['#name']] + : []; + if ((!isset($pane_storage['reuse_profile']) || !$pane_storage['reuse_profile']) && !in_array($last_parent, ['edit_button', 'cancel_button'])) { + $form_display = EntityFormDisplay::collectRenderDisplay($element['#profile'], 'default'); + $form_display->extractFormValues($element['#profile'], $element, $form_state); + $form_display->validateFormValues($element['#profile'], $element, $form_state); + } } /** @@ -131,11 +306,131 @@ public static function validateForm(array &$element, FormStateInterface $form_st * The form element. * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. + * + * @throws \Drupal\Core\Entity\EntityStorageException */ public static function submitForm(array &$element, FormStateInterface $form_state) { - $form_display = EntityFormDisplay::collectRenderDisplay($element['#profile'], 'default'); - $form_display->extractFormValues($element['#profile'], $element, $form_state); - $element['#profile']->save(); + $storage = $form_state->getStorage(); + if (isset($storage['pane_' . $element['#name']]['reuse_profile']) + && $storage['pane_' . $element['#name']]['reuse_profile']) { + // Load the current profile by ID or by callback. + $profile = (is_numeric($element['#reuse_profile_source'])) + ? \Drupal::entityTypeManager()->getStorage('profile')->load($element['#reuse_profile_source']) + : call_user_func($element['#reuse_profile_source'], $element, $form_state, $form_state->getCompleteForm()); + if (!$profile instanceof ProfileInterface) { + throw new EntityStorageException('The profile to reuse could not be determined from the provided arguments.'); + } + $element['#profile'] = $profile; + } + elseif (isset($storage['pane_' . $element['#name']]['op']) + && in_array($storage['pane_' . $element['#name']]['op'], ['add', 'edit'])) { + $form_display = EntityFormDisplay::collectRenderDisplay($element['#profile'], 'default'); + $form_display->extractFormValues($element['#profile'], $element, $form_state); + $element['#profile']->save(); + } + } + + /** + * Profile AJAX callback. + * + * @param array $form + * The complete form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param \Symfony\Component\HttpFoundation\Request $request + * The Request object. + * + * @return array + * The form element replace the wrapper with. + */ + public static function profileAjax(array &$form, FormStateInterface $form_state, Request $request) { + $triggering_element = $form_state->getTriggeringElement(); + $array_parents = $triggering_element['#array_parents']; + array_pop($array_parents); + $profile_form = NestedArray::getValue($form, $array_parents); + return $profile_form; + } + + /** + * The #element_validate callback for the reuse profile checkbox. + * + * @param array $element + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function profileReuseValidate(array $element, FormStateInterface $form_state) { + $form = $form_state->getCompleteForm(); + $parents = $element['#array_parents']; + array_pop($parents); + $storage = $form_state->getStorage(); + $profile_form = NestedArray::getValue($form, $parents); + $storage['pane_' . $profile_form['#name']]['reuse_profile'] = $element['#value']; + $form_state->setStorage($storage); + } + + /** + * The #element_validate callback for the profiles dropdown select. + * + * @param array $element + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function profileSelectValidate(array $element, FormStateInterface $form_state) { + $form = $form_state->getCompleteForm(); + $parents = $element['#array_parents']; + array_pop($parents); + $storage = $form_state->getStorage(); + $profile_form = NestedArray::getValue($form, $parents); + $storage['pane_' . $profile_form['#name']]['profile_selection'] = $element['#value']; + if (isset($storage['pane_' . $profile_form['#name']]['op']) && $storage['pane_' . $profile_form['#name']]['op'] != 'edit') { + $storage['pane_' . $profile_form['#name']]['op'] = $element['#value'] == 'new_profile' ? 'add' : 'view'; + } + $form_state->setStorage($storage); + } + + /** + * The #element_validate callback for the edit and cancel buttons. + * + * @param array $element + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public static function profileEditCancelValidate(array $element, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + if ($triggering_element && $triggering_element['#id'] === $element['#id']) { + $parents = $triggering_element['#array_parents']; + $last_parent = array_pop($parents); + if (in_array($last_parent, ['edit_button', 'cancel_button'])) { + $form = $form_state->getCompleteForm(); + $profile_form = NestedArray::getValue($form, $parents); + $storage = $form_state->getStorage(); + $storage['pane_' . $profile_form['#name']]['op'] = ($last_parent == 'edit_button') ? 'edit' : 'view'; + $form_state->setStorage($storage); + } + } + } + + /** + * Hides fields from the element which should not be visible. + * + * @param array $element + * The element. + * @param array $retain + * An array of child element keys to keep visible. + * @param bool $force_retained + * Whether or not to force retained fields to stay visible. + */ + protected static function hideProfileFields(array &$element, $retain = [], $force_retained = FALSE) { + foreach (Element::children($element) as $key) { + if (!in_array($key, $retain)) { + $element[$key]['#access'] = FALSE; + } elseif ($force_retained) { + $element[$key]['#access'] = TRUE; + } + } } }