diff --git a/modules/checkout/src/Plugin/Commerce/CheckoutPane/BillingInformation.php b/modules/checkout/src/Plugin/Commerce/CheckoutPane/BillingInformation.php index e53b0f92..99de2881 100644 --- a/modules/checkout/src/Plugin/Commerce/CheckoutPane/BillingInformation.php +++ b/modules/checkout/src/Plugin/Commerce/CheckoutPane/BillingInformation.php @@ -33,6 +33,7 @@ class BillingInformation extends CheckoutPaneBase implements CheckoutPaneInterfa */ public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) { $store = $this->order->getStore(); + $billing_profile = $this->order->getBillingProfile(); if (!$billing_profile) { $profile_storage = $this->entityTypeManager->getStorage('profile'); @@ -44,7 +45,11 @@ class BillingInformation extends CheckoutPaneBase implements CheckoutPaneInterfa $pane_form['profile'] = [ '#type' => 'commerce_profile_select', + '#title' => $this->t('Select an address'), + '#create_title' => $this->t('+ Enter a new address'), '#default_value' => $billing_profile, + '#profile_type' => 'customer', + '#profile_uid' => $this->order->getCustomerId(), '#default_country' => $store->getAddress()->getCountryCode(), '#available_countries' => $store->getBillingCountries(), ]; diff --git a/modules/order/commerce_order.libraries.yml b/modules/order/commerce_order.libraries.yml index 3005b4e8..654f394f 100644 --- a/modules/order/commerce_order.libraries.yml +++ b/modules/order/commerce_order.libraries.yml @@ -9,3 +9,15 @@ total_summary: css: layout: css/commerce_order.total_summary.css: {} + +profile_select: + version: VERSION + js: + js/profile-select.js: {} + css: + component: + css/commerce_order.profile_select.css: {} + dependencies: + - core/jquery + - core/jquery.once + - core/drupal diff --git a/modules/order/config/install/profile.type.customer.yml b/modules/order/config/install/profile.type.customer.yml index c1cb63ef..c455be72 100644 --- a/modules/order/config/install/profile.type.customer.yml +++ b/modules/order/config/install/profile.type.customer.yml @@ -9,3 +9,4 @@ label: Customer registration: false multiple: true weight: 0 +use_revisions: true diff --git a/modules/order/css/commerce_order.profile_select.css b/modules/order/css/commerce_order.profile_select.css new file mode 100644 index 00000000..3f0946d7 --- /dev/null +++ b/modules/order/css/commerce_order.profile_select.css @@ -0,0 +1,16 @@ +.profile-select .visible-on-edit { + display: none; +} +.profile-select.editing .visible-on-edit { + display: block; +} +.profile-select.editing .hidden-on-edit { + display: none; +} + +.profile-select.creating .visible-on-create { + display: block; +} +.profile-select.creating .hidden-on-create { + display: none; +} diff --git a/modules/order/js/profile-select.js b/modules/order/js/profile-select.js new file mode 100644 index 00000000..f48b6e6d --- /dev/null +++ b/modules/order/js/profile-select.js @@ -0,0 +1,39 @@ +(function ($, Drupal) { + Drupal.behaviors.profileSelect = { + attach: function (context) { + function toggleRequired($profileSelect, required) { + $profileSelect.find('.required').each(function (key, el) { + el.required = required; + }); + } + + var $selects = $(context).find('.profile-select').once(); + if ($selects.length > 0) { + $selects.each(function (index, el) { + var $profileSelect = $(el); + var $inputs = $profileSelect.find('input:not([type=submit]):not([type=button]),select,textarea'); + $profileSelect.data('originalValues', $inputs.serializeArray()); + toggleRequired($profileSelect, false); + + $profileSelect.find('.edit-profile').once().click(function (event) { + event.preventDefault(); + toggleRequired($profileSelect, true); + $profileSelect.toggleClass('editing'); + + $profileSelect.find('.cancel-edit-profile').once().click(function (event) { + event.preventDefault(); + $profileSelect.toggleClass('editing'); + toggleRequired($profileSelect, false); + var originalValues = $profileSelect.data('originalValues'); + $.each(originalValues, function (key, field) { + console.log(field); + console.log($profileSelect.find('[name="' + field['name'] + '"]').val()); + $profileSelect.find('[name="' + field['name'] + '"]').val(field['value']); + }); + }); + }); + }); + } + } + }; +})(jQuery, Drupal); diff --git a/modules/order/src/Element/ProfileSelect.php b/modules/order/src/Element/ProfileSelect.php index 6aacf1c6..aa20d935 100644 --- a/modules/order/src/Element/ProfileSelect.php +++ b/modules/order/src/Element/ProfileSelect.php @@ -3,10 +3,16 @@ namespace Drupal\commerce_order\Element; use Drupal\commerce\Element\CommerceElementTrait; +use Drupal\commerce\EntityHelper; +use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\NestedArray; use Drupal\Core\Entity\Entity\EntityFormDisplay; +use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Element\RenderElement; +use Drupal\Core\Session\AccountInterface; use Drupal\profile\Entity\ProfileInterface; +use Drupal\user\Entity\User; /** * Provides a form element for selecting a customer profile. @@ -16,13 +22,17 @@ use Drupal\profile\Entity\ProfileInterface; * $form['billing_profile'] = [ * '#type' => 'commerce_profile_select', * '#default_value' => $profile, + * '#profile_type' => 'customer', + * '#profile_uid' => \Drupal::currentUser()->id(), * '#default_country' => 'FR', * '#available_countries' => ['US', 'FR'], * ]; * @endcode + * * To access the profile in validation or submission callbacks, use - * $form['billing_profile']['#profile']. Due to Drupal core limitations the - * profile can't be accessed via $form_state->getValue('billing_profile'). + * - $form_state->getValue('billing_profile') + * Or, (kept for backwards compatibility) + * - $form['billing_profile']['#profile']. * * @RenderElement("commerce_profile_select") */ @@ -36,16 +46,28 @@ class ProfileSelect extends RenderElement { public function getInfo() { $class = get_class($this); return [ + '#title' => t('Select a profile'), + '#create_title' => t('+ Enter a new profile'), + + // Needed for creating new profiles, since #default_value may be empty. + // @todo need to implement. + '#profile_type' => NULL, + '#profile_uid' => NULL, + + // Whether profiles should always be loaded in the latest revision. + // Disable when editing historical data, such as placed orders. + '#profile_latest_revision' => TRUE, + // The country to select if the address widget doesn't have a default. '#default_country' => NULL, // A list of country codes. If empty, all countries will be available. '#available_countries' => [], - // The profile entity operated on. Required. + // The profile entity operated on. '#default_value' => NULL, '#process' => [ [$class, 'attachElementSubmit'], - [$class, 'processForm'], + [$class, 'processElement'], ], '#element_validate' => [ [$class, 'validateElementSubmit'], @@ -54,32 +76,31 @@ class ProfileSelect extends RenderElement { '#commerce_element_submit' => [ [$class, 'submitForm'], ], + '#after_build' => [ + [$class, 'clearValues'], + ], '#theme_wrappers' => ['container'], ]; } /** - * Builds the element form. + * Validates the element properties. + * + * This also provides support breaking changes made that added the + * profile_type and profile_uid values instead of passing a default + * value. * * @param array $element - * The form element being processed. - * @param \Drupal\Core\Form\FormStateInterface $form_state - * The current state of the form. - * @param array $complete_form - * The complete form structure. + * The form element. * * @throws \InvalidArgumentException - * Thrown when #default_value is empty or not an entity, or when - * #available_countries is not an array of country codes. - * - * @return array - * The processed form element. + * Thrown if an element property is invalid, or empty but required. */ - public static function processForm(array $element, FormStateInterface $form_state, array &$complete_form) { + public static function validateElementProperties(array &$element) { if (empty($element['#default_value'])) { throw new \InvalidArgumentException('The commerce_profile_select element requires the #default_value property.'); } - elseif (isset($element['#default_value']) && !($element['#default_value'] instanceof ProfileInterface)) { + elseif (!($element['#default_value'] instanceof ProfileInterface)) { throw new \InvalidArgumentException('The commerce_profile_select #default_value property must be a profile entity.'); } if (!is_array($element['#available_countries'])) { @@ -91,12 +112,178 @@ class ProfileSelect extends RenderElement { $element['#default_country'] = NULL; } } + } + + /** + * Builds the element form. + * + * @param array $element + * The form element being processed. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $complete_form + * The complete form structure. + * + * @return array + * The processed form element. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public static function processElement(array $element, FormStateInterface $form_state, array &$complete_form) { + self::validateElementProperties($element); + + $element['#attached']['library'][] = 'commerce_order/profile_select'; + $element['#attributes']['class'][] = 'profile-select'; + + /** @var \Drupal\profile\ProfileStorageInterface $profile_storage */ + $profile_storage = \Drupal::entityTypeManager()->getStorage('profile'); + /** @var \Drupal\profile\Entity\ProfileInterface $default_profile */ + $default_profile = $element['#default_value']; + + $current_user = \Drupal::currentUser(); + + // This is the latest revision if reports that is the default revision, + // and the element allows editing the current revision through the + // #profile_latest_revision flag. + $default_value_is_latest_revision = $default_profile->isDefaultRevision() && $element['#profile_latest_revision']; + $default_profile_label = $default_profile->label(); + // @todo Remove ?: check after https://www.drupal.org/project/commerce/issues/2995325 + $owner = $default_profile->getOwner() ?: User::getAnonymousUser(); + $profile_type = $default_profile->bundle(); + + // If the owner is a registered user, load their other active profiles for + // selection and reuse. + $available_profiles = static::getAvailableProfiles($owner, $profile_type); + // If the default value is a new profile, automatically select their + // default profile. + if ($default_profile->isNew()) { + foreach ($available_profiles as $available_profile) { + if ($available_profile->isDefault()) { + $element['#default_value'] = $available_profile; + $default_profile = $available_profile; + break; + } + } + } + // Handle a form rebuild and grab the selected profile value. + $selected_available_profile = $form_state->getValue(array_merge($element['#parents'], ['available_profiles'])); + if ($selected_available_profile) { + if ($selected_available_profile == '_new') { + $selected_available_profile = $profile_storage->create([ + 'type' => $default_profile->bundle(), + 'uid' => $default_profile->getOwnerId(), + ]); + } + // We are still going to use the existing profile, which is referenced at + // a previous revision. + elseif ($selected_available_profile == '_existing') { + $selected_available_profile = $default_profile; + } + else { + $selected_available_profile = $profile_storage->load($selected_available_profile); + } + $element['#default_value'] = $selected_available_profile; + $default_profile = $selected_available_profile; + } + + // Set #profile to keep BC. + $element['#profile'] = $default_profile; + + $id_prefix = implode('-', $element['#parents']); + $wrapper_id = Html::getId($id_prefix . '-ajax-wrapper'); + $element = [ + '#tree' => TRUE, + '#prefix' => '
', + '#suffix' => '
', + // Pass the id along to other methods. + '#wrapper_id' => $wrapper_id, + '#element_mode' => $form_state->get('element_mode') ?: 'view', + ] + $element; + + // If the profile is new, apply the `creating` class so that the form is + // displayed automatically. + if ($default_profile->isNew()) { + $element['#attributes']['class'][] = 'creating'; + } + + $available_profiles_default_value = $default_profile->id() ?: '_new'; + $available_profiles_options = EntityHelper::extractLabels($available_profiles); + + if ($owner->hasPermission('create customer profile') || $current_user->hasPermission('create customer profile')) { + $available_profiles_options += ['_new' => $element['#create_title']]; + } + + // If the original default value is not the default revision, ensure it + // persists as an option to prevent unexpected changes in data. + if (!$default_value_is_latest_revision) { + $available_profiles_options = [ + '_existing' => t(':label (Original)', [':label' => $default_profile_label]), + ] + $available_profiles_options; + $available_profiles_default_value = '_existing'; + } + + $element['available_profiles'] = [ + '#type' => 'select', + '#title' => $element['#title'], + '#options' => $available_profiles_options, + '#default_value' => $available_profiles_default_value, + '#access' => !empty($available_profiles), + '#ajax' => [ + 'callback' => [get_called_class(), 'ajaxRefresh'], + 'wrapper' => $wrapper_id, + ], + '#prefix' => '
', + '#suffix' => '
', + '#attributes' => [ + 'class' => ['available-profiles'], + ], + ]; + + $view_display = EntityViewDisplay::collectRenderDisplay($default_profile, 'default'); + $element['profile_view'] = $view_display->build($element['#default_value']); + $element['profile_view']['#prefix'] = '
'; + $element['profile_view']['#suffix'] = '
'; + $element['profile_view']['#access'] = !$default_profile->isNew(); + $element['profile_view']['edit'] = [ + '#type' => 'button', + '#value' => t('Edit'), + '#limit_validation_errors' => [], + '#attributes' => [ + 'class' => ['edit-profile'], + ], + '#access' => $default_profile->isDefaultRevision() && ($default_profile->access('update', $owner) || $default_profile->access('update', $current_user)), + // Ensure this edit button shows below any other fields. + '#weight' => 100, + ]; + + $form_display = EntityFormDisplay::collectRenderDisplay($default_profile, 'default'); + $element['profile_form'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['visible-on-edit visible-on-create'], + ], + '#access' => $default_profile->isDefaultRevision(), + '#parents' => $element['#parents'], + 'cancel' => [ + '#type' => 'button', + '#value' => t('Cancel changes'), + '#limit_validation_errors' => [], + '#weight' => 100, + '#attributes' => [ + 'class' => [ + 'cancel-edit-profile', + 'hidden-on-create', + ], + ], + ], + ]; + + $form_display->buildForm($default_profile, $element['profile_form'], $form_state); - $element['#profile'] = $element['#default_value']; - $form_display = EntityFormDisplay::collectRenderDisplay($element['#profile'], 'default'); - $form_display->buildForm($element['#profile'], $element, $form_state); - if (!empty($element['address']['widget'][0])) { - $widget_element = &$element['address']['widget'][0]; + // Adjust the address widget on the profile, if present. + if (!empty($element['profile_form']['address']['widget'][0])) { + $widget_element = &$element['profile_form']['address']['widget'][0]; // Remove the details wrapper from the address widget. $widget_element['#type'] = 'container'; // Provide a default country. @@ -108,7 +295,6 @@ class ProfileSelect extends RenderElement { $widget_element['address']['#available_countries'] = $element['#available_countries']; } } - return $element; } @@ -125,9 +311,15 @@ class ProfileSelect extends RenderElement { * 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); + $selected_available_profile = self::getSelectedAvailableProfile($element, $form_state); + $form_display = EntityFormDisplay::collectRenderDisplay($element['#default_value'], 'default'); + $form_display->extractFormValues($selected_available_profile, $element, $form_state); + $form_display->validateFormValues($selected_available_profile, $element, $form_state); + + // Set the profile as a value in the `profile` key of the form state. + $element_clone = $element; + $element_clone['#parents'][] = 'profile'; + $form_state->setValueForElement($element_clone, $selected_available_profile); } /** @@ -137,11 +329,129 @@ class ProfileSelect extends RenderElement { * The form element. * @param \Drupal\Core\Form\FormStateInterface $form_state * The current state of the form. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @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(); + $selected_available_profile = self::getSelectedAvailableProfile($element, $form_state); + $form_display = EntityFormDisplay::collectRenderDisplay($selected_available_profile, 'default'); + $form_display->extractFormValues($selected_available_profile, $element, $form_state); + + // If the profile was modified, enforce a new revision. + // When the _existing option is chosen, there will be no changes reported + // preventing an accidental flag for the revision. + if ($selected_available_profile->hasTranslationChanges()) { + // If this is an old revision, we want to save directly to it, and not + // a new revision. But if it is the latest revision, we want to ensure + // that changes don't affect references to it. + if ($selected_available_profile->isLatestRevision()) { + $selected_available_profile->setNewRevision(TRUE); + } + $selected_available_profile->save(); + } + + // Set the profile as a value in the `profile` key of the form state. + $element_clone = $element; + $element_clone['#parents'][] = 'profile'; + $form_state->setValueForElement($element_clone, $selected_available_profile); + $element['#profile'] = $selected_available_profile; + } + + /** + * Gets the available profiles for the user that can be selected. + * + * @param \Drupal\Core\Session\AccountInterface $account + * The account. + * @param string $profile_type + * The profile type. + * + * @return array|\Drupal\profile\Entity\ProfileInterface[] + * An array of profiles. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected static function getAvailableProfiles(AccountInterface $account, $profile_type) { + /** @var \Drupal\profile\ProfileStorageInterface $profile_storage */ + $profile_storage = \Drupal::entityTypeManager()->getStorage('profile'); + $available_profiles = []; + if ($account->isAuthenticated()) { + $available_profiles = $profile_storage->loadMultipleByUser($account, $profile_type, TRUE); + } + return $available_profiles; + } + + /** + * Gets the selected available profile. + * + * @param array $element + * The form element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\profile\Entity\ProfileInterface + * The selected profile. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected static function getSelectedAvailableProfile(array $element, FormStateInterface $form_state) { + /** @var \Drupal\profile\ProfileStorageInterface $profile_storage */ + $profile_storage = \Drupal::entityTypeManager()->getStorage('profile'); + $selected_available_profile = $form_state->getValue(array_merge($element['#parents'], ['available_profiles'])); + if ($selected_available_profile == '_new') { + return $profile_storage->create([ + 'type' => $element['#default_value']->bundle(), + 'uid' => $element['#default_value']->getOwnerId(), + ]); + } + // We are still going to use the existing profile, which is referenced at + // a previous revision. + elseif ($selected_available_profile == '_existing') { + return $element['#default_value']; + } + else { + return $profile_storage->load($selected_available_profile); + } + } + + /** + * Ajax callback. + */ + public static function ajaxRefresh(array &$form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $element = NestedArray::getValue($form, array_slice($triggering_element['#array_parents'], 0, -1)); + return $element; + } + + /** + * Clears dependent form values when the profile changes. + * + * Clears all input, so that the default values for a new profile form will + * be used, instead of the last input. + */ + public static function clearValues(array $element, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + if (!$triggering_element) { + return $element; + } + if (end($triggering_element['#array_parents']) != 'available_profiles') { + return $element; + } + + $triggering_element_parents = array_slice($triggering_element['#array_parents'], 0, -1); + $input = &$form_state->getUserInput(); + + if (NestedArray::keyExists($input, $triggering_element_parents)) { + // Remove any input for profile fields. + array_walk(NestedArray::getValue($input, $triggering_element_parents), function (&$item, $key) { + $item = ($key == 'available_profiles') ? $item : NULL; + }); + } + + return $element; } } diff --git a/modules/order/src/Plugin/Field/FieldWidget/BillingProfileWidget.php b/modules/order/src/Plugin/Field/FieldWidget/BillingProfileWidget.php index 5fe768c1..1f8a6914 100644 --- a/modules/order/src/Plugin/Field/FieldWidget/BillingProfileWidget.php +++ b/modules/order/src/Plugin/Field/FieldWidget/BillingProfileWidget.php @@ -88,7 +88,10 @@ class BillingProfileWidget extends WidgetBase implements ContainerFactoryPluginI $element['#type'] = 'fieldset'; $element['profile'] = [ '#type' => 'commerce_profile_select', + '#title' => $this->t('Select an address'), + '#create_title' => $this->t('+ Enter a new address'), '#default_value' => $profile, + '#profile_latest_revision' => $order->getState()->value == 'draft', '#default_country' => $store->getAddress()->getCountryCode(), '#available_countries' => $store->getBillingCountries(), ]; @@ -107,6 +110,7 @@ class BillingProfileWidget extends WidgetBase implements ContainerFactoryPluginI public function massageFormValues(array $values, array $form, FormStateInterface $form_state) { $new_values = []; foreach ($values as $delta => $value) { + // @todo Get the value from $value rather than element directly. $element = NestedArray::getValue($form, $value['array_parents']); $new_values[$delta]['entity'] = $element['profile']['#profile']; } diff --git a/modules/order/tests/modules/commerce_order_test/commerce_order_test.routing.yml b/modules/order/tests/modules/commerce_order_test/commerce_order_test.routing.yml new file mode 100644 index 00000000..7d62a766 --- /dev/null +++ b/modules/order/tests/modules/commerce_order_test/commerce_order_test.routing.yml @@ -0,0 +1,9 @@ +commerce_order_test.profile_select_form: + path: '/commerce_order_test/profile_select_form' + defaults: + _form: '\Drupal\commerce_order_test\Form\ProfileSelectTestForm' + _title: 'Profile select test form' + requirements: + _access: 'TRUE' + options: + no_cache: TRUE diff --git a/modules/order/tests/modules/commerce_order_test/src/Form/ProfileSelectTestForm.php b/modules/order/tests/modules/commerce_order_test/src/Form/ProfileSelectTestForm.php new file mode 100644 index 00000000..3131495c --- /dev/null +++ b/modules/order/tests/modules/commerce_order_test/src/Form/ProfileSelectTestForm.php @@ -0,0 +1,51 @@ + 'commerce_profile_select', + '#title' => $this->t('Select a profile'), + '#default_value' => Profile::create([ + 'type' => 'customer', + 'uid' => \Drupal::currentUser()->id(), + ]), + '#profile_type' => 'customer', + '#owner_uid' => \Drupal::currentUser()->id(), + '#available_countries' => ['HU', 'FR', 'US', 'RS', 'DE'], + ]; + $form['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Submit'), + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $profile = $form_state->getValue(['profile', 'profile']); + drupal_set_message($this->t('Profile selected: :label', [':label' => $profile->label()])); + } + +} diff --git a/modules/order/tests/src/Functional/OrderAdminTest.php b/modules/order/tests/src/Functional/OrderAdminTest.php index 9410fe36..4c7041de 100644 --- a/modules/order/tests/src/Functional/OrderAdminTest.php +++ b/modules/order/tests/src/Functional/OrderAdminTest.php @@ -15,29 +15,6 @@ use Drupal\profile\Entity\Profile; */ class OrderAdminTest extends OrderBrowserTestBase { - /** - * The profile to test against. - * - * @var \Drupal\profile\Entity\Profile - */ - protected $billingProfile; - - /** - * {@inheritdoc} - */ - protected function setUp() { - parent::setUp(); - \Drupal::service('module_installer')->install(['profile']); - - $profile_values = [ - 'type' => 'customer', - 'uid' => 1, - 'status' => 1, - ]; - $this->billingProfile = Profile::create($profile_values); - $this->billingProfile->save(); - } - /** * Tests creating/editing an Order. */ @@ -75,7 +52,10 @@ class OrderAdminTest extends OrderBrowserTestBase { 'order_items[form][inline_entity_form][unit_price][0][amount][number]' => '9.99', ]; $this->submitForm($edit, 'Create order item'); - $this->submitForm([], t('Edit')); + // There are two "edit" buttons, one for the profile and one for the order + // items. + // @todo AJAX on the order item IEF shouldn't cause read mode on profile. + $this->getSession()->getPage()->pressButton('edit-order-items-entities-0-actions-ief-entity-edit'); $this->assertSession()->fieldExists('order_items[form][inline_entity_form][entities][0][form][purchased_entity][0][target_id]'); $this->assertSession()->fieldExists('order_items[form][inline_entity_form][entities][0][form][quantity][0][value]'); $this->assertSession()->fieldExists('order_items[form][inline_entity_form][entities][0][form][unit_price][0][amount][number]'); @@ -117,7 +97,7 @@ class OrderAdminTest extends OrderBrowserTestBase { $this->drupalGet('/admin/commerce/orders'); $order_number = $this->getSession()->getPage()->find('css', 'tr td.views-field-order-number'); - $this->assertEquals(1, count($order_number), 'Order exists in the table.'); + $this->assertNotEmpty($order_number, 'Order exists in the table.'); $order = Order::load(1); $this->assertEquals(1, count($order->getItems())); @@ -137,6 +117,12 @@ class OrderAdminTest extends OrderBrowserTestBase { ]); $order->save(); + /** @var \Drupal\commerce_order\OrderItemStorageInterface $order_item_store */ + $order_item_store = $this->container->get('entity_type.manager')->getStorage('commerce_order_item'); + $order_item = $order_item_store->createFromPurchasableEntity($this->variation); + $order_item->save(); + $order->addItem($order_item); + $adjustments = []; $adjustments[] = new Adjustment([ 'type' => 'custom', @@ -158,6 +144,44 @@ class OrderAdminTest extends OrderBrowserTestBase { $this->assertSession()->fieldValueEquals('adjustments[1][definition][label]', 'Handling fee'); $this->assertSession()->optionExists('adjustments[2][type]', 'Custom'); $this->assertSession()->optionNotExists('adjustments[2][type]', 'Test order adjustment type'); + + $this->getSession()->getPage()->fillField('First name', 'Frederick'); + $this->getSession()->getPage()->fillField('Last name', 'Pabst'); + $this->getSession()->getPage()->fillField('Street address', 'Pabst Blue Ribbon Dr'); + $this->getSession()->getPage()->fillField('City', 'Milwaukee'); + $this->getSession()->getPage()->fillField('State', 'WI'); + $this->getSession()->getPage()->fillField('Zip code', '53177'); + + $this->getSession()->getPage()->pressButton('Save'); + + $this->drupalGet($order->toUrl('edit-form')); + + $this->assertSession()->selectExists('Select an address'); + $this->assertSession()->optionExists('Select an address', 'Pabst Blue Ribbon Dr (Original)'); + $this->assertSession()->optionExists('Select an address', 'Pabst Blue Ribbon Dr'); + $this->assertSession()->optionExists('Select an address', '+ Enter a new address'); + + $this->assertSession()->pageTextContains('Frederick Pabst'); + $this->assertSession()->pageTextContains('Pabst Blue Ribbon Dr'); + $this->assertSession()->pageTextContains('Milwaukee, WI 53177'); + $this->assertSession()->pageTextContains('United States'); + + // Ensure the billing profile keeps the same revision data. + $this->container->get('entity_type.manager')->getStorage('commerce_order')->resetCache(); + $order = Order::load($order->id()); + // We reload it, because the order will give us a specific revision. + /** @var \Drupal\profile\Entity\Profile $billing_profile */ + $billing_profile = Profile::load($order->getBillingProfile()->id()); + $billing_profile->setNewRevision(); + /** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $address */ + $address = $billing_profile->get('address')->first(); + $address->set('given_name', 'Joseph'); + $address->set('family_name', 'Schlitz '); + $billing_profile->save(); + + $this->drupalGet($order->toUrl('edit-form')); + $this->assertSession()->pageTextContains('Frederick Pabst'); + $this->assertSession()->pageTextNotContains('Joseph Schlitz'); } /** diff --git a/modules/order/tests/src/Functional/OrderBrowserTestBase.php b/modules/order/tests/src/Functional/OrderBrowserTestBase.php index 97216422..e2474c0a 100644 --- a/modules/order/tests/src/Functional/OrderBrowserTestBase.php +++ b/modules/order/tests/src/Functional/OrderBrowserTestBase.php @@ -36,6 +36,9 @@ abstract class OrderBrowserTestBase extends CommerceBrowserTestBase { 'administer commerce_order', 'administer commerce_order_type', 'access commerce_order overview', + 'administer profile', + 'create customer profile', + 'update own customer profile', ], parent::getAdministratorPermissions()); } diff --git a/modules/order/tests/src/FunctionalJavascript/OrderAdminTest.php b/modules/order/tests/src/FunctionalJavascript/OrderAdminTest.php new file mode 100644 index 00000000..6f89d01c --- /dev/null +++ b/modules/order/tests/src/FunctionalJavascript/OrderAdminTest.php @@ -0,0 +1,60 @@ +drupalGet(Url::fromRoute('entity.commerce_order.add_page')); + $this->getSession()->getPage()->checkField('New customer'); + $this->waitForAjaxToFinish(); + $this->getSession()->getPage()->fillField('Email', 'email@example.com'); + $this->getSession()->getPage()->pressButton('Create'); + + $this->getSession()->getPage()->fillField('First name', 'Celia'); + $this->getSession()->getPage()->fillField('Last name', 'Engeseth'); + $this->getSession()->getPage()->fillField('Street address', '8502 Pilgrim St.'); + $this->getSession()->getPage()->fillField('City', 'Mokena'); + $this->getSession()->getPage()->fillField('State', 'IL'); + $this->getSession()->getPage()->fillField('Zip code', '60448'); + + $product_variation_field = $this->getSession()->getPage()->find('named', ['field', 'Product variation']); + $product_variation_field->setValue($this->variation->getTitle()); + $this->getSession()->getDriver()->keyDown($product_variation_field->getXpath(), ' '); + $this->assertSession()->waitOnAutocomplete(); + /** @var \Behat\Mink\Element\NodeElement[] $results */ + $results = $this->getSession()->getPage()->findAll('css', '.ui-autocomplete li'); + $this->assertCount(1, $results); + $results[0]->click(); + $this->getSession()->getPage()->checkField('Override the unit price'); + $this->getSession()->getPage()->fillField('Unit price', '12.00'); + $this->getSession()->getPage()->pressButton('Create order item'); + $this->assertSession()->assertWaitOnAjaxRequest(); + + $this->getSession()->getPage()->pressButton('Add new order item'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->fieldExists('Product variation'); + // Order item IEF does not affect profile_select for billing. + $this->assertSession()->fieldValueEquals('Street address', '8502 Pilgrim St.'); + + $open_ief = $this->getSession()->getPage()->find('css', '[data-drupal-selector="edit-order-items-form-inline-entity-form"]'); + $open_ief->pressButton('Cancel'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->getSession()->getPage()->pressButton('Save'); + + $this->assertSession()->pageTextContains('The order has been successfully saved.'); + $this->getSession()->getPage()->clickLink('Edit'); + $this->saveHtmlOutput(); + + $this->assertSession()->fieldExists('Select an address'); + $this->assertSession()->optionExists('Select an address', '8502 Pilgrim St.'); + $this->assertSession()->optionExists('Select an address', '+ Enter a new address'); + } + +} diff --git a/modules/order/tests/src/FunctionalJavascript/ProfileSelectTest.php b/modules/order/tests/src/FunctionalJavascript/ProfileSelectTest.php new file mode 100644 index 00000000..6b70ab3b --- /dev/null +++ b/modules/order/tests/src/FunctionalJavascript/ProfileSelectTest.php @@ -0,0 +1,326 @@ + 'HU', + 'given_name' => 'Gustav', + 'family_name' => 'Mahler', + 'address_line1' => 'Teréz körút 7', + 'locality' => 'Budapest', + 'postal_code' => '1067', + ]; + + /** + * Profile address values. + * + * @var array + */ + protected $address2 = [ + 'country_code' => 'DE', + 'given_name' => 'Johann Sebastian', + 'family_name' => 'Bach', + 'address_line1' => 'Thomaskirchhof 15', + 'locality' => 'Leipzig', + 'postal_code' => '04109', + ]; + + /** + * The profile storage. + * + * @var \Drupal\profile\ProfileStorageInterface + */ + protected $profileStorage; + + /** + * Modules to enable. + * + * @var array + */ + public static $modules = [ + 'commerce_order_test', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $this->profileStorage = $this->container->get('entity_type.manager') + ->getStorage('profile'); + } + + /** + * Tests the profile select form element for anonymous user. + */ + public function testAnonymous() { + $this->drupalLogout(); + $address_fields = $this->address1; + $this->drupalGet(Url::fromRoute('commerce_order_test.profile_select_form')); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->fieldNotExists('Select a profile'); + $this->getSession()->getPage()->fillField('Country', $address_fields['country_code']); + $this->waitForAjaxToFinish(); + $edit = []; + foreach ($address_fields as $key => $value) { + if ($key == 'country_code') { + continue; + } + $edit['profile[address][0][address][' . $key . ']'] = $value; + } + $this->submitForm($edit, 'Submit'); + /** @var \Drupal\profile\Entity\ProfileInterface $profile */ + $profile = $this->profileStorage->load(1); + $this->assertSession()->responseContains(sprintf('Profile selected: %s', $profile->label())); + /** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $address */ + $address = $profile->get('address')->first(); + $this->assertEquals($address_fields['country_code'], $address->getCountryCode()); + $this->assertEquals($address_fields['given_name'], $address->getGivenName()); + $this->assertEquals($address_fields['family_name'], $address->getFamilyName()); + $this->assertEquals($address_fields['address_line1'], $address->getAddressLine1()); + $this->assertEquals($address_fields['locality'], $address->getLocality()); + $this->assertEquals($address_fields['postal_code'], $address->getPostalCode()); + } + + /** + * Tests the profile select form element for anonymous user. + */ + public function testAuthenticatedNoExistingProfiles() { + $account = $this->createUser([ + 'create customer profile', + 'update own customer profile', + ]); + $this->drupalLogin($account); + $address_fields = $this->address1; + $this->drupalGet(Url::fromRoute('commerce_order_test.profile_select_form')); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->fieldNotExists('Select a profile'); + $this->getSession()->getPage()->fillField('Country', $address_fields['country_code']); + $this->waitForAjaxToFinish(); + $edit = []; + foreach ($address_fields as $key => $value) { + if ($key == 'country_code') { + continue; + } + $edit['profile[address][0][address][' . $key . ']'] = $value; + } + $this->submitForm($edit, 'Submit'); + /** @var \Drupal\profile\Entity\ProfileInterface $profile */ + $profile = $this->profileStorage->load(1); + $this->assertSession()->responseContains(sprintf('Profile selected: %s', $profile->label())); + /** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $address */ + $address = $profile->get('address')->first(); + $this->assertEquals($address_fields['country_code'], $address->getCountryCode()); + $this->assertEquals($address_fields['given_name'], $address->getGivenName()); + $this->assertEquals($address_fields['family_name'], $address->getFamilyName()); + $this->assertEquals($address_fields['address_line1'], $address->getAddressLine1()); + $this->assertEquals($address_fields['locality'], $address->getLocality()); + $this->assertEquals($address_fields['postal_code'], $address->getPostalCode()); + } + + /** + * Tests the profile select form element for authenticated user. + */ + public function testProfileSelectAuthenticated() { + $account = $this->createUser([ + 'create customer profile', + 'update own customer profile', + ]); + $profile_storage = $this->container->get('entity_type.manager') + ->getStorage('profile'); + /** @var \Drupal\profile\Entity\ProfileInterface $profile_address1 */ + $profile_address1 = $profile_storage->create([ + 'type' => 'customer', + 'uid' => $account->id(), + 'address' => $this->address1, + ]); + $profile_address1->save(); + /** @var \Drupal\profile\Entity\ProfileInterface $profile_address2 */ + $profile_address2 = $profile_storage->create([ + 'type' => 'customer', + 'uid' => $account->id(), + 'address' => $this->address2, + ]); + $profile_address2->setDefault(TRUE); + $profile_address2->save(); + $this->drupalLogin($account); + $this->drupalGet(Url::fromRoute('commerce_order_test.profile_select_form')); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('Select a profile'); + // The last created profile should be selected by default. + $this->assertSession()->pageTextContains($this->address2['locality']); + $this->getSession()->getPage()->fillField('Select a profile', $profile_address1->id()); + $this->waitForAjaxToFinish(); + $this->assertSession()->pageTextContains($this->address1['locality']); + $this->submitForm([], 'Submit'); + $this->assertSession()->responseContains(sprintf('Profile selected: %s', $profile_address1->label())); + $profile_storage->resetCache([$profile_address1->id()]); + $profile_address1 = $profile_storage->load($profile_address1->id()); + /** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $address */ + $address = $profile_address1->get('address')->first(); + // Assert that field values have not changed. + $this->assertEquals($this->address1['country_code'], $address->getCountryCode()); + $this->assertEquals($this->address1['given_name'], $address->getGivenName()); + $this->assertEquals($this->address1['family_name'], $address->getFamilyName()); + $this->assertEquals($this->address1['address_line1'], $address->getAddressLine1()); + $this->assertEquals($this->address1['locality'], $address->getLocality()); + $this->assertEquals($this->address1['postal_code'], $address->getPostalCode()); + } + + /** + * Tests the profile select form element for authenticated user. + */ + public function testProfileSelectAuthenticatedCreateNew() { + $account = $this->createUser([ + 'create customer profile', + 'update own customer profile', + ]); + $address_fields = $this->address2; + /** @var \Drupal\profile\Entity\ProfileInterface $profile_address1 */ + $profile_address1 = $this->profileStorage->create([ + 'type' => 'customer', + 'uid' => $account->id(), + 'address' => $this->address1, + ]); + $profile_address1->save(); + $this->drupalLogin($account); + $this->drupalGet(Url::fromRoute('commerce_order_test.profile_select_form')); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->fieldExists('Select a profile'); + // The last created profile should be selected by default. + $this->assertSession()->pageTextContains($this->address1['locality']); + $this->getSession()->getPage()->fillField('Select a profile', '_new'); + $this->waitForAjaxToFinish(); + $this->getSession()->getPage()->fillField('Country', $address_fields['country_code']); + $this->waitForAjaxToFinish(); + $edit = []; + foreach ($address_fields as $key => $value) { + if ($key == 'country_code') { + continue; + } + $edit['profile[address][0][address][' . $key . ']'] = $value; + } + $this->submitForm($edit, 'Submit'); + $new_profile = $this->profileStorage->load(2); + /** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $address */ + $address = $new_profile->get('address')->first(); + + $this->assertSession()->responseContains(sprintf('Profile selected: %s', $new_profile->label())); + // Assert that field values have not changed. + $this->assertEquals($this->address2['country_code'], $address->getCountryCode()); + $this->assertEquals($this->address2['given_name'], $address->getGivenName()); + $this->assertEquals($this->address2['family_name'], $address->getFamilyName()); + $this->assertEquals($this->address2['address_line1'], $address->getAddressLine1()); + $this->assertEquals($this->address2['locality'], $address->getLocality()); + $this->assertEquals($this->address2['postal_code'], $address->getPostalCode()); + } + + /** + * Tests the profile select form element for authenticated user. + */ + public function testProfileSelectAuthenticatedEdit() { + $account = $this->createUser([ + 'create customer profile', + 'update own customer profile', + ]); + /** @var \Drupal\profile\Entity\ProfileInterface $profile_address1 */ + $profile_address1 = $this->profileStorage->create([ + 'type' => 'customer', + 'uid' => $account->id(), + 'address' => $this->address1, + ]); + $profile_address1->save(); + /** @var \Drupal\profile\Entity\ProfileInterface $profile_address2 */ + $profile_address2 = $this->profileStorage->create([ + 'type' => 'customer', + 'uid' => $account->id(), + 'address' => $this->address2, + ]); + $profile_address2->setDefault(TRUE); + $profile_address2->save(); + $this->drupalLogin($account); + // Edit a profile. + $this->drupalGet(Url::fromRoute('commerce_order_test.profile_select_form')); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->fieldExists('Select a profile'); + // The last created profile should be selected by default. + $this->assertSession()->pageTextContains($this->address2['locality']); + $this->getSession()->getPage()->pressButton('Edit'); + $this->waitForAjaxToFinish(); + foreach ($this->address2 as $key => $value) { + $this->assertSession()->fieldValueEquals('profile[address][0][address][' . $key . ']', $value); + } + $this->getSession()->getPage()->fillField('Street address', 'Andrássy út 22'); + $this->submitForm([], 'Submit'); + $this->profileStorage->resetCache([$profile_address2->id()]); + $profile_address2 = $this->profileStorage->load($profile_address2->id()); + /** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $address */ + $address = $profile_address2->get('address')->first(); + $this->assertSession()->responseContains(sprintf('Profile selected: %s', $profile_address2->label())); + // Assert that field values have not changed. + $this->assertEquals($this->address2['country_code'], $address->getCountryCode()); + $this->assertEquals($this->address2['given_name'], $address->getGivenName()); + $this->assertEquals($this->address2['family_name'], $address->getFamilyName()); + $this->assertEquals('Andrássy út 22', $address->getAddressLine1()); + $this->assertEquals($this->address2['locality'], $address->getLocality()); + $this->assertEquals($this->address2['postal_code'], $address->getPostalCode()); + } + + /** + * Tests that editing and then canceling does not change data on save. + * + * @group debug + */ + public function testEditThenCancelDataIntegrity() { + $account = $this->createUser([ + 'create customer profile', + 'update own customer profile', + ]); + /** @var \Drupal\profile\Entity\ProfileInterface $profile_address1 */ + $profile_address1 = $this->profileStorage->create([ + 'type' => 'customer', + 'uid' => $account->id(), + 'address' => $this->address1, + ]); + $profile_address1->save(); + $this->drupalLogin($account); + $this->drupalGet(Url::fromRoute('commerce_order_test.profile_select_form')); + $this->getSession()->getPage()->pressButton('Edit'); + $this->getSession()->getPage()->fillField('Street address', 'Andrássy út 22'); + $this->getSession()->getPage()->pressButton('Cancel changes'); + $this->getSession()->getPage()->pressButton('Submit'); + + $this->profileStorage->resetCache([$profile_address1->id()]); + $profile_address1 = $this->profileStorage->load($profile_address1->id()); + /** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $address */ + $address = $profile_address1->get('address')->first(); + + // Assert that field values have not changed. + $this->assertEquals($this->address1['country_code'], $address->getCountryCode()); + $this->assertEquals($this->address1['given_name'], $address->getGivenName()); + $this->assertEquals($this->address1['family_name'], $address->getFamilyName()); + $this->assertEquals($this->address1['address_line1'], $address->getAddressLine1()); + $this->assertEquals($this->address1['locality'], $address->getLocality()); + $this->assertEquals($this->address1['postal_code'], $address->getPostalCode()); + + } + +} diff --git a/modules/order/tests/src/Kernel/ProfileSelectTest.php b/modules/order/tests/src/Kernel/ProfileSelectTest.php new file mode 100644 index 00000000..8681c525 --- /dev/null +++ b/modules/order/tests/src/Kernel/ProfileSelectTest.php @@ -0,0 +1,774 @@ +installConfig(['commerce_order']); + $this->installEntitySchema('profile'); + $this->formBuilder = $this->container->get('form_builder'); + + // Create a uid1 so permissions don't get bypassed later on. + $uid1 = $this->createUser(); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'profile_select_test_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + // Default basic element definition. + $form['profile'] = [ + '#type' => 'commerce_profile_select', + '#title' => 'Select an address', + '#create_title' => '+ Enter a new address', + '#default_value' => $form_state->get('profile') ?: Profile::create([ + 'type' => 'customer', + 'uid' => $form_state->get('user') ?: User::getAnonymousUser(), + ]), + '#profile_latest_revision' => TRUE, + '#default_country' => 'US', + '#available_countries' => ['HU', 'FR', 'US', 'RS', 'DE'], + ]; + + switch ($this->formTestCase) { + case 'testValidateElementPropertiesDefaultValueEmpty': + $form['profile']['#default_value'] = NULL; + break; + + case 'testValidateElementPropertiesDefaultValueInstance': + $form['profile']['#default_value'] = '14'; + break; + + case 'testValidateElementPropertiesAvailableCountries': + $form['profile']['#available_countries'] = 'US'; + break; + + case 'testDefaultCountryIsNotValid': + $form['profile']['#default_country'] = 'CA'; + break; + + default: + // Do nothing, the default definition is enough to test with. + break; + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + // If the form is being validated. + // The value has been set on the "profile" key of the element. + $profile = $form_state->getValue(['profile', 'profile']); + $this->assertInstanceOf(Profile::class, $profile); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) {} + + /** + * Tests that the element expects a default value. + */ + public function testValidateElementPropertiesDefaultValueEmpty() { + $this->setExpectedException(\InvalidArgumentException::class, 'The commerce_profile_select element requires the #default_value property.'); + $this->formTestCase = __FUNCTION__; + $this->buildTestForm(); + } + + /** + * Tests that the element expects a default value of ProfileInterface. + */ + public function testValidateElementPropertiesDefaultValueInstance() { + $this->setExpectedException(\InvalidArgumentException::class, 'The commerce_profile_select #default_value property must be a profile entity.'); + $this->formTestCase = __FUNCTION__; + $this->buildTestForm(); + } + + /** + * Tests that the element expects a default value of ProfileInterface. + */ + public function testValidateElementPropertiesAvailableCountries() { + $this->setExpectedException(\InvalidArgumentException::class, 'The commerce_profile_select #available_countries property must be an array.'); + $this->formTestCase = __FUNCTION__; + $this->buildTestForm(); + } + + /** + * Tests that an invalid default country resets to NULL. + */ + public function testValidateElementPropertiesDefaultCountry() { + $this->formTestCase = 'testDefaultCountryIsValid'; + $form = $this->buildTestForm(); + $this->assertEquals('US', $form['profile']['#default_country']); + + $this->formTestCase = 'testDefaultCountryIsNotValid'; + $form = $this->buildTestForm(); + $this->assertNull($form['profile']['#default_country']); + } + + /** + * Tests the available profiles select list. + * + * Ensures: + * - Anonymous users do not see the select list + * - Users without existing profiles do not see the select list + * - Users with existing profiles see the select list + * - The select list defaults to the current user's profile. + * - The element's default value is the user's default profile. + */ + public function testAvailableProfilesSelectList() { + $this->formTestCase = __FUNCTION__; + + // Test as anonymous user, which should never show the select list. + $form = $this->buildTestForm(); + $this->assertFalse($form['profile']['available_profiles']['#access']); + + // Test that a user without previous profiles does not see the select list. + $user = $this->createUser([], [ + 'create customer profile', + ]); + $form = $this->buildTestForm([ + 'user' => $user, + ]); + $this->assertFalse($form['profile']['available_profiles']['#access']); + + // Create profiles for the user, assert the select list is available. + $test_profile1 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'organization' => '', + 'country_code' => 'FR', + 'postal_code' => '75002', + 'locality' => 'Paris', + 'address_line1' => 'A french street', + 'given_name' => 'John', + 'family_name' => 'LeSmith', + ], + 'uid' => $user->id(), + ]); + $test_profile1->setDefault(TRUE); + $test_profile1->save(); + $test_profile2 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $user->id(), + ]); + $test_profile2->save(); + + $form = $this->buildTestForm([ + 'user' => $user, + ]); + + $this->assertTrue($form['profile']['available_profiles']['#access']); + $this->assertCount(3, $form['profile']['available_profiles']['#options']); + $this->assertEquals([ + $test_profile1->id() => $test_profile1->label(), + $test_profile2->id() => $test_profile2->label(), + '_new' => '+ Enter a new address', + ], $form['profile']['available_profiles']['#options']); + $this->assertEquals($test_profile1->id(), $form['profile']['available_profiles']['#default_value']); + $this->assertEquals($test_profile1->id(), $form['profile']['#default_value']->id()); + + // If we mark the test_profile2 as default, it should be the default option. + $test_profile2->setDefault(TRUE); + $test_profile2->save(); + + $form = $this->buildTestForm([ + 'user' => $user, + ]); + $this->assertEquals($test_profile2->id(), $form['profile']['available_profiles']['#default_value']); + $this->assertEquals($test_profile2->id(), $form['profile']['#default_value']->id()); + } + + /** + * Tests that the element default value respects provided profile. + */ + public function testAvailableProfilesListWithProvidedDefaultValue() { + $user = $this->createUser(); + $test_profile1 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'organization' => '', + 'country_code' => 'FR', + 'postal_code' => '75002', + 'locality' => 'Paris', + 'address_line1' => 'A french street', + 'given_name' => 'John', + 'family_name' => 'LeSmith', + ], + 'uid' => $user->id(), + ]); + $test_profile1->setDefault(TRUE); + $test_profile1->save(); + $test_profile2 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $user->id(), + ]); + $test_profile2->save(); + + // Pass the second profile to form, so it is the one being modified. + $form = $this->buildTestForm([ + 'profile' => $test_profile2, + ]); + $this->assertEquals($test_profile2->id(), $form['profile']['available_profiles']['#default_value']); + $this->assertEquals($test_profile2->id(), $form['profile']['#default_value']->id()); + } + + /** + * Tests that the #profile attribute contains the profile value. + */ + public function testProfilePropertyOnElement() { + $form = $this->buildTestForm(); + $this->assertInstanceOf( + Profile::class, + $form['profile']['#profile'] + ); + $this->assertInstanceOf( + Profile::class, + $form['profile']['#default_value'] + ); + } + + /** + * Tests using a previous revision with the profile select element. + * + * This asserts that a previous revision passed into the element will not + * be allowed to be modified. + */ + public function testLatestRevision() { + $user = $this->createUser([], [ + 'create customer profile', + ]); + $test_profile1 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'organization' => '', + 'country_code' => 'FR', + 'postal_code' => '75002', + 'locality' => 'Paris', + 'address_line1' => 'A french street', + 'given_name' => 'John', + 'family_name' => 'LeSmith', + ], + 'uid' => $user->id(), + ]); + $test_profile1->setDefault(TRUE); + $test_profile1->save(); + $test_profile2 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $user->id(), + ]); + $test_profile2->save(); + + $test_profile2_revision_id = $test_profile2->getRevisionId(); + + // Mark it as default, and create a new revision. + $test_profile2 = $this->reloadEntity($test_profile2); + $test_profile2->setDefault(TRUE); + $test_profile2->setNewRevision(); + $test_profile2->save(); + + $this->assertNotEquals( + $test_profile2_revision_id, + $test_profile2->getRevisionId() + ); + + $original_test_profile2 = $this->container->get('entity_type.manager') + ->getStorage('profile') + ->loadRevision($test_profile2_revision_id); + + $this->assertFalse($original_test_profile2->isDefaultRevision()); + $this->assertTrue($test_profile2->isDefaultRevision()); + + // Pass the second profile to form, so it is the one being modified. + $form = $this->buildTestForm([ + 'profile' => $original_test_profile2, + ]); + $this->assertCount(4, $form['profile']['available_profiles']['#options']); + $this->assertEquals([ + '_existing' => t(':label (Original)', [':label' => $original_test_profile2->label()]), + $test_profile1->id() => $test_profile1->label(), + $test_profile2->id() => $test_profile2->label(), + '_new' => '+ Enter a new address', + ], $form['profile']['available_profiles']['#options']); + $this->assertEquals('_existing', $form['profile']['available_profiles']['#default_value']); + $this->assertFalse($form['profile']['profile_view']['edit']['#access']); + } + + /** + * Tess the element when passing values from the select list. + */ + public function testAvailableProfilesFormStateValue() { + $user = $this->createUser(); + $test_profile1 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'organization' => '', + 'country_code' => 'FR', + 'postal_code' => '75002', + 'locality' => 'Paris', + 'address_line1' => 'A french street', + 'given_name' => 'John', + 'family_name' => 'LeSmith', + ], + 'uid' => $user->id(), + ]); + $test_profile1->setDefault(TRUE); + $test_profile1->save(); + $test_profile2 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $user->id(), + ]); + $test_profile2->save(); + + $test_profile2_revision_id = $test_profile2->getRevisionId(); + + // Mark it as default, and create a new revision. + $test_profile2 = $this->reloadEntity($test_profile2); + $test_profile2->setDefault(TRUE); + $test_profile2->setNewRevision(); + $test_profile2->save(); + + $this->assertNotEquals( + $test_profile2_revision_id, + $test_profile2->getRevisionId() + ); + + $original_test_profile2 = $this->container->get('entity_type.manager') + ->getStorage('profile') + ->loadRevision($test_profile2_revision_id); + + $this->assertFalse($original_test_profile2->isDefaultRevision()); + $this->assertTrue($test_profile2->isDefaultRevision()); + + // Pass a previous revision to the form, but specify we want a new profile. + $form = $this->buildTestForm([ + 'profile' => $original_test_profile2, + 'values' => [ + 'profile' => [ + 'available_profiles' => '_new', + ], + ], + 'input' => [ + 'profile' => [ + 'available_profiles' => '_new', + ], + ], + ]); + $this->assertEquals('_new', $form['profile']['available_profiles']['#value']); + $this->assertTrue($form['profile']['#profile']->isNew()); + + // Pass a previous revision to the form, but specify the first test profile. + $form = $this->buildTestForm([ + 'profile' => $original_test_profile2, + 'values' => [ + 'profile' => [ + 'available_profiles' => $test_profile1->id(), + ], + ], + 'input' => [ + 'profile' => [ + 'available_profiles' => $test_profile1->id(), + ], + ], + ]); + $this->assertEquals($test_profile1->id(), $form['profile']['available_profiles']['#value']); + $this->assertEquals($test_profile1->id(), $form['profile']['#profile']->id()); + + // Pass in the latest revision for the default value, and ensure that we + // do not receive `_existing` as the option. + $form = $this->buildTestForm([ + 'profile' => $original_test_profile2, + 'values' => [ + 'profile' => [ + 'available_profiles' => $test_profile2->id(), + ], + ], + 'input' => [ + 'profile' => [ + 'available_profiles' => $test_profile2->id(), + ], + ], + ]); + $this->assertEquals($test_profile2->id(), $form['profile']['available_profiles']['#value']); + $this->assertEquals($test_profile2->id(), $form['profile']['#profile']->id()); + } + + /** + * Tests that the `_new` option is controlled by permission access. + */ + public function testCreateAccess() { + // Create a user who has profiles, but does not have the ability to create + // new. This replicates sites where users have a set of profiles to select + // from based on custom logic but cannot create new ones. + $user = $this->createUser([], []); + $test_profile1 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'organization' => '', + 'country_code' => 'FR', + 'postal_code' => '75002', + 'locality' => 'Paris', + 'address_line1' => 'A french street', + 'given_name' => 'John', + 'family_name' => 'LeSmith', + ], + 'uid' => $user->id(), + ]); + $test_profile1->setDefault(TRUE); + $test_profile1->save(); + $test_profile2 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $user->id(), + ]); + $test_profile2->save(); + + $form = $this->buildTestForm([ + 'user' => $user, + ]); + + $this->assertCount(2, $form['profile']['available_profiles']['#options']); + $this->assertEquals([ + $test_profile1->id() => $test_profile1->label(), + $test_profile2->id() => $test_profile2->label(), + ], $form['profile']['available_profiles']['#options']); + } + + /** + * Tests editing an available profile is based on permissions. + */ + public function testEditAccess() { + // Create a user who has profiles, but does not have the ability to create + // new. This replicates sites where users have a set of profiles to select + // from based on custom logic but cannot create new ones. + $user = $this->createUser([], [ + 'create customer profile', + ]); + $test_profile1 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'organization' => '', + 'country_code' => 'FR', + 'postal_code' => '75002', + 'locality' => 'Paris', + 'address_line1' => 'A french street', + 'given_name' => 'John', + 'family_name' => 'LeSmith', + ], + 'uid' => $user->id(), + ]); + $test_profile1->setDefault(TRUE); + $test_profile1->save(); + $test_profile2 = Profile::create([ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $user->id(), + ]); + $test_profile2->save(); + + $form = $this->buildTestForm([ + 'user' => $user, + 'profile' => $test_profile1, + ]); + + $this->assertCount(3, $form['profile']['available_profiles']['#options']); + $this->assertEquals($test_profile1->id(), $form['profile']['available_profiles']['#default_value']); + + $this->assertFalse($form['profile']['profile_view']['edit']['#access']); + + $user = $this->createUser([], [ + 'create customer profile', + 'update own profile', + 'update own customer profile', + ]); + $test_profile1->setOwner($user); + $test_profile1->save(); + $test_profile2->setOwner($user); + $test_profile2->save(); + + $form = $this->buildTestForm([ + 'user' => $user, + 'profile' => $test_profile1, + ]); + $this->assertTrue($form['profile']['profile_view']['edit']['#access']); + } + + /** + * Tests the validation of the element. + * + * Assertions are made in this forms validate method. The main assertion + * is that the proposed profile is set as the element value during the + * validation process, so that other elements can validate against that + * selected profile. + * + * @see \Drupal\Tests\commerce_order\Kernel\ProfileSelectTest::validateForm + */ + public function testElementValidation() { + $form = $this->buildTestForm(); + + $form_validator = $this->container->get('form_validator'); + + $form_state = new FormState(); + $form_state->setProgrammed(); + $form_validator->validateForm($this->getFormId(), $form, $form_state); + } + + /** + * Tests the submission of the element. + */ + public function testElementSubmission() { + $user = $this->createUser([], [ + 'create customer profile', + ]); + $form_state = new FormState(); + $form_state->setFormState([ + 'user' => $user, + 'values' => [ + 'profile' => [ + 'available_profiles' => '_new', + 'address' => [ + 0 => [ + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + ], + ], + ], + ], + 'input' => [ + 'profile' => [ + 'available_profiles' => '_new', + 'address' => [ + 0 => [ + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + ], + ], + ], + ], + ]); + $this->formBuilder->submitForm($this, $form_state); + + $complete_form = $form_state->getCompleteForm(); + // Assert the profile is stored on the element's #profile property and + // has been saved. + $profile = $complete_form['profile']['#profile']; + $this->assertInstanceOf(Profile::class, $profile); + $this->assertFalse($profile->isNew()); + + // Assert the profile was set to be the element value as well. + /** @var \Drupal\profile\Entity\ProfileInterface $profile */ + $profile = $form_state->getValue(['profile', 'profile']); + $this->assertInstanceOf(Profile::class, $profile); + $this->assertFalse($profile->isNew()); + + $profile_storage = $this->container->get('entity_type.manager')->getStorage('profile'); + /** @var \Drupal\profile\Entity\ProfileInterface $initial_profile_revision */ + $initial_profile_revision = $profile_storage->loadRevision($profile->getRevisionId()); + + // Resubmit the form with the profile we created. However, we're going to + // modify the address. This should cause a new revision to be created. + $form_state = new FormState(); + $form_state->setFormState([ + 'user' => $user, + 'profile' => $profile, + 'values' => [ + 'profile' => [ + 'available_profiles' => $profile->id(), + 'address' => [ + 0 => [ + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Joseph', + 'family_name' => 'Schlitz', + ], + ], + ], + ], + ], + 'input' => [ + 'profile' => [ + 'available_profiles' => $profile->id(), + 'address' => [ + 0 => [ + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Joseph', + 'family_name' => 'Schlitz', + ], + ], + ], + ], + ], + ]); + $this->formBuilder->submitForm($this, $form_state); + + /** @var \Drupal\profile\Entity\ProfileInterface $updated_profile */ + $updated_profile = $form_state->getValue(['profile', 'profile']); + $this->assertInstanceOf(Profile::class, $updated_profile); + $this->assertEquals($initial_profile_revision->id(), $updated_profile->id()); + $this->assertNotEquals( + $initial_profile_revision->getRevisionId(), + $updated_profile->getRevisionId() + ); + } + + /** + * Build the test form. + * + * @param array $form_state_additions + * An array of values to add to the form state. + * + * @return array + * The rendered form. + * + * @throws \Drupal\Core\Form\EnforcedResponseException + * @throws \Drupal\Core\Form\FormAjaxException + */ + protected function buildTestForm(array $form_state_additions = []) { + // Programmatically submit the form. + $form_state = new FormState(); + $form_state->setProgrammed(); + $form_state->setProcessInput(); + $form_state->setFormState($form_state_additions); + $form = $this->formBuilder->buildForm($this, $form_state); + + // If form values were passed, rebuild the form to simulate AJAX. + if (!empty($form_state_additions['values'])) { + $form_state->setMethod('GET'); + $form_state->setValues($form_state_additions['values']); + $form = $this->formBuilder->rebuildForm($this->getFormId(), $form_state, $form); + } + return $form; + } + +} diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php index a91aabeb..77942152 100644 --- a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php +++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php @@ -270,6 +270,8 @@ class PaymentInformation extends CheckoutPaneBase { $pane_form['billing_information'] = [ '#type' => 'commerce_profile_select', + '#title' => t('Select an address'), + '#create_title' => t('+ Enter a new address'), '#default_value' => $billing_profile, '#default_country' => $store->getAddress()->getCountryCode(), '#available_countries' => $store->getBillingCountries(), diff --git a/modules/payment/src/PluginForm/PaymentMethodAddForm.php b/modules/payment/src/PluginForm/PaymentMethodAddForm.php index 07f8b82c..42e0e2de 100644 --- a/modules/payment/src/PluginForm/PaymentMethodAddForm.php +++ b/modules/payment/src/PluginForm/PaymentMethodAddForm.php @@ -74,6 +74,8 @@ class PaymentMethodAddForm extends PaymentGatewayFormBase { $form['billing_information'] = [ '#parents' => array_merge($form['#parents'], ['billing_information']), '#type' => 'commerce_profile_select', + '#title' => t('Select an address'), + '#create_title' => t('+ Enter a new address'), '#default_value' => $billing_profile, '#default_country' => $store ? $store->getAddress()->getCountryCode() : NULL, '#available_countries' => $store ? $store->getBillingCountries() : [], diff --git a/modules/payment/src/PluginForm/PaymentMethodEditForm.php b/modules/payment/src/PluginForm/PaymentMethodEditForm.php index 8d195b22..a96a5a5c 100644 --- a/modules/payment/src/PluginForm/PaymentMethodEditForm.php +++ b/modules/payment/src/PluginForm/PaymentMethodEditForm.php @@ -67,6 +67,8 @@ class PaymentMethodEditForm extends PaymentGatewayFormBase implements ContainerI $form['billing_information'] = [ '#parents' => array_merge($form['#parents'], ['billing_information']), '#type' => 'commerce_profile_select', + '#title' => t('Select an address'), + '#create_title' => t('+ Enter a new address'), '#default_value' => $billing_profile, '#default_country' => $store ? $store->getAddress()->getCountryCode() : NULL, '#available_countries' => $store ? $store->getBillingCountries() : [], diff --git a/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php b/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php index f77716ad..fe9acf50 100644 --- a/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php +++ b/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php @@ -237,6 +237,8 @@ class PaymentCheckoutTest extends CommerceBrowserTestBase { $radio_button = $page->findField('Example'); $this->assertNull($radio_button); $this->assertSession()->fieldExists('payment_information[billing_information][address][0][address][postal_code]'); + $this->assertSession()->fieldExists('Select an address'); + $this->assertSession()->pageTextContains('Pabst Blue Ribbon Dr'); } /** @@ -371,7 +373,6 @@ class PaymentCheckoutTest extends CommerceBrowserTestBase { $radio_button = $this->getSession()->getPage()->findField('Example'); $radio_button->click(); $this->waitForAjaxToFinish(); - $this->submitForm([ 'payment_information[billing_information][address][0][address][given_name]' => 'Johnny', 'payment_information[billing_information][address][0][address][family_name]' => 'Appleseed',