diff --git a/src/js/form-validation.test.ts b/src/js/form-validation.test.ts new file mode 100644 index 000000000..f5bcfa070 --- /dev/null +++ b/src/js/form-validation.test.ts @@ -0,0 +1,54 @@ +/** + * For the full copyright and license information, please view the + * LICENSE.md file that was distributed with this source code. + */ +import selectorsMap from '@constants/selectors-map'; +import initFormValidation from './form-validation'; + +const setUp = (markup: string) => { + document.body.innerHTML = markup; + + window.Theme = { + ...window.Theme, + selectors: selectorsMap, + }; + + initFormValidation(); +}; + +describe('Form validation', () => { + it('binds the submit button nested inside the form', () => { + setUp(` +
+ + +
+ `); + + const form = document.querySelector('#f') as HTMLFormElement; + const button = document.querySelector('button') as HTMLButtonElement; + button.click(); + + expect(form.classList.contains('was-validated')).toBe(true); + }); + + it('binds a submit button attached through the form attribute', () => { + // WHY: the checkout address step keeps its submit button outside the form element, because + // the step also renders address
elements that must not be nested inside it. + setUp(` +
+ + + `); + + const form = document.querySelector('#f') as HTMLFormElement; + const button = document.querySelector('button') as HTMLButtonElement; + button.click(); + + expect(form.classList.contains('was-validated')).toBe(true); + }); + + it('does not throw when the form has no submit button', () => { + expect(() => setUp('
')).not.toThrow(); + }); +}); diff --git a/src/js/form-validation.ts b/src/js/form-validation.ts index 5c590433d..906e33464 100644 --- a/src/js/form-validation.ts +++ b/src/js/form-validation.ts @@ -9,7 +9,12 @@ const initFormValidation = (selector?: string) => { const formValidationList = document.querySelectorAll(selector ?? formValidationMap.default); formValidationList.forEach((formElement: HTMLFormElement) => { - const submitButton = formElement.querySelector(formValidationMap.submitButton); + // WHY: a control can belong to a form through the `form` attribute rather than DOM + // containment, and querySelector only ever sees descendants. form.elements covers both, + // which the checkout address step relies on to keep its submit button out of the + // address
elements rendered inside the same step. + const submitButton = Array.from(formElement.elements) + .find((element): element is HTMLButtonElement => element.matches(formValidationMap.submitButton)) ?? null; if (submitButton) { submitButton.addEventListener('click', (event) => { diff --git a/templates/checkout/_partials/address-selector-block.tpl b/templates/checkout/_partials/address-selector-block.tpl index 75e3bfeb8..9ecaad96d 100644 --- a/templates/checkout/_partials/address-selector-block.tpl +++ b/templates/checkout/_partials/address-selector-block.tpl @@ -15,6 +15,7 @@ class="form-check-input" name="{$name}" value="{$address.id}" + {if isset($form_id)}form="{$form_id}"{/if} {if $address.id == $selected}checked{/if} aria-label="{l s='Select address: %addressAlias%' sprintf=['%addressAlias%' => $address.alias|lower] d='Shop.Theme.Actions'}" aria-describedby="address-{$address.id}" @@ -60,7 +61,7 @@ {if $interactive}
- +
{/if} {/block} diff --git a/templates/checkout/_partials/checkout-step-buttons.tpl b/templates/checkout/_partials/checkout-step-buttons.tpl index 97b591c47..13e8b7b65 100644 --- a/templates/checkout/_partials/checkout-step-buttons.tpl +++ b/templates/checkout/_partials/checkout-step-buttons.tpl @@ -11,7 +11,7 @@ {/if} {if isset($next_step) && (!isset($show_next_button) || $show_next_button)} - diff --git a/templates/checkout/_partials/steps/addresses.tpl b/templates/checkout/_partials/steps/addresses.tpl index 15f151751..362f712fa 100644 --- a/templates/checkout/_partials/steps/addresses.tpl +++ b/templates/checkout/_partials/steps/addresses.tpl @@ -5,110 +5,118 @@ {extends file='checkout/_partials/steps/checkout-step.tpl'} {block name='step_content'} + {$addresses_form_id = 'checkout-addresses-form'}
+ {* WHY: this step also renders full address elements (checkout/_partials/address-form.tpl). + Wrapping those in this form produced nested tags: the HTML parser drops the inner start + tag and lets the inner close THIS form instead, which orphaned the address selector and + the Continue button that follow it. This form therefore stays empty and its controls are + attached explicitly through the form attribute. *}
- {if $use_same_address} -

- {if $cart.is_virtual} - {l s='The selected address will be used as your personal address (for invoice).' d='Shop.Theme.Checkout'} - {else} - {l s='The selected address will be used both as your personal address (for invoice) and as your delivery address.' d='Shop.Theme.Checkout'} - {/if} -

+ >
+ {if $use_same_address} +

+ {if $cart.is_virtual} + {l s='The selected address will be used as your personal address (for invoice).' d='Shop.Theme.Checkout'} + {else} + {l s='The selected address will be used both as your personal address (for invoice) and as your delivery address.' d='Shop.Theme.Checkout'} + {/if} +

+ {else} +

{l s='Shipping Address' d='Shop.Theme.Checkout'}

+ {/if} + + {if $show_delivery_address_form} +
+ {render file = 'checkout/_partials/address-form.tpl' + ui = $address_form + use_same_address = $use_same_address + type = "delivery" + form_has_continue_button = $form_has_continue_button + } +
+ {elseif $customer.addresses|count> 0} +
+ {include file = 'checkout/_partials/address-selector-block.tpl' + addresses = $customer.addresses + name = "id_address_delivery" + selected = $id_address_delivery + type = "delivery" + form_id = $addresses_form_id + interactive = !$show_delivery_address_form and !$show_invoice_address_form + } +
+ + {if isset($delivery_address_error)} +

{$delivery_address_error.exception}

{else} -

{l s='Shipping Address' d='Shop.Theme.Checkout'}

+ {/if} - {if $show_delivery_address_form} -
+ + + {if $use_same_address && !$cart.is_virtual} + + {l s='Billing address differs from shipping address' d='Shop.Theme.Checkout'} + + {/if} + {/if} + + {if !$use_same_address} +

{l s='Your Invoice Address' d='Shop.Theme.Checkout'}

+ + {if $show_invoice_address_form} +
{render file = 'checkout/_partials/address-form.tpl' ui = $address_form use_same_address = $use_same_address - type = "delivery" + type = "invoice" form_has_continue_button = $form_has_continue_button }
- {elseif $customer.addresses|count> 0} -
+ {else} +
{include file = 'checkout/_partials/address-selector-block.tpl' addresses = $customer.addresses - name = "id_address_delivery" - selected = $id_address_delivery - type = "delivery" + name = "id_address_invoice" + selected = $id_address_invoice + type = "invoice" + form_id = $addresses_form_id interactive = !$show_delivery_address_form and !$show_invoice_address_form }
- {if isset($delivery_address_error)} -

{$delivery_address_error.exception}

+ {if isset($invoice_address_error)} +

{$invoice_address_error.exception}

{else} - + {/if} - - - {if $use_same_address && !$cart.is_virtual} - - {l s='Billing address differs from shipping address' d='Shop.Theme.Checkout'} - - {/if} + + + {l s='Add new address' d='Shop.Theme.Actions'} + {/if} + {/if} - {if !$use_same_address} -

{l s='Your Invoice Address' d='Shop.Theme.Checkout'}

- - {if $show_invoice_address_form} -
- {render file = 'checkout/_partials/address-form.tpl' - ui = $address_form - use_same_address = $use_same_address - type = "invoice" - form_has_continue_button = $form_has_continue_button - } -
- {else} -
- {include file = 'checkout/_partials/address-selector-block.tpl' - addresses = $customer.addresses - name = "id_address_invoice" - selected = $id_address_invoice - type = "invoice" - interactive = !$show_delivery_address_form and !$show_invoice_address_form - } -
- - {if isset($invoice_address_error)} -

{$invoice_address_error.exception}

- {else} - - {/if} +
+ {include file='checkout/_partials/checkout-step-buttons.tpl' show_next_button=false} - - - {l s='Add new address' d='Shop.Theme.Actions'} - - {/if} + {if !$form_has_continue_button} + {include file='checkout/_partials/checkout-step-buttons.tpl' show_back_button=false submit_name='confirm-addresses' submit_value='1' form_id=$addresses_form_id} + {/if} - -
- {include file='checkout/_partials/checkout-step-buttons.tpl' show_next_button=false} - - {if !$form_has_continue_button} - {include file='checkout/_partials/checkout-step-buttons.tpl' show_back_button=false submit_name='confirm-addresses' submit_value='1'} - - {/if} -
- +
{capture name="address_selector_bottom"}{hook h='displayAddressSelectorBottom'}{/capture} {if $smarty.capture.address_selector_bottom}