Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/js/form-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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(`
<form id="f" data-ps-action="form-validation">
<input name="a" required>
<button type="submit" data-ps-action="form-validation-submit">Send</button>
</form>
`);

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 <form> elements that must not be nested inside it.
setUp(`
<form id="f" data-ps-action="form-validation"></form>
<input name="a" form="f" required>
<button type="submit" form="f" data-ps-action="form-validation-submit">Send</button>
`);

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('<form id="f" data-ps-action="form-validation"></form>')).not.toThrow();
});
});
7 changes: 6 additions & 1 deletion src/js/form-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ const initFormValidation = (selector?: string) => {
const formValidationList = document.querySelectorAll<HTMLFormElement>(selector ?? formValidationMap.default);

formValidationList.forEach((formElement: HTMLFormElement) => {
const submitButton = formElement.querySelector<HTMLButtonElement>(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 <form> 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) => {
Expand Down
3 changes: 2 additions & 1 deletion templates/checkout/_partials/address-selector-block.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -60,7 +61,7 @@

{if $interactive}
<div class="d-none">
<button class="ps-hidden-by-js form-control-submit center-block" type="submit">{l s='Save' d='Shop.Theme.Actions'}</button>
<button class="ps-hidden-by-js form-control-submit center-block" type="submit"{if isset($form_id)} form="{$form_id}"{/if}>{l s='Save' d='Shop.Theme.Actions'}</button>
</div>
{/if}
{/block}
2 changes: 1 addition & 1 deletion templates/checkout/_partials/checkout-step-buttons.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{/if}

{if isset($next_step) && (!isset($show_next_button) || $show_next_button)}
<button type="{if isset($submit_type)}{$submit_type}{else}submit{/if}" class="btn btn-primary" {if isset($submit_name)}name="{$submit_name}"{/if} {if isset($submit_value)}value="{$submit_value}"{/if}>
<button type="{if isset($submit_type)}{$submit_type}{else}submit{/if}" class="btn btn-primary" {if isset($submit_name)}name="{$submit_name}"{/if} {if isset($submit_value)}value="{$submit_value}"{/if}{if isset($form_id)} form="{$form_id}"{/if}>
{l s='Continue to %step_title%' d='Shop.Theme.Actions' sprintf=['%step_title%' => $next_step.title]}
<i class="material-icons rtl-flip" aria-hidden="true">&#xE5C8;</i>
</button>
Expand Down
160 changes: 84 additions & 76 deletions templates/checkout/_partials/steps/addresses.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -5,110 +5,118 @@
{extends file='checkout/_partials/steps/checkout-step.tpl'}

{block name='step_content'}
{$addresses_form_id = 'checkout-addresses-form'}
<div class="js-address-form">
{* WHY: this step also renders full address <form> elements (checkout/_partials/address-form.tpl).
Wrapping those in this form produced nested <form> tags: the HTML parser drops the inner start
tag and lets the inner </form> 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. *}
<form
id="{$addresses_form_id}"
method="POST"
action="{url entity='order' params=['id_address' => $id_address]}"
data-refresh-url="{url entity='order' params=['ajax' => 1, 'action' => 'addressForm', 'id_address' => $id_address]}"
data-ps-action="form-validation"
>
{if $use_same_address}
<p>
{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}
</p>
></form>
{if $use_same_address}
<p>
{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}
</p>
{else}
<p class="h3">{l s='Shipping Address' d='Shop.Theme.Checkout'}</p>
{/if}

{if $show_delivery_address_form}
<div id="delivery-address">
{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
}
</div>
{elseif $customer.addresses|count> 0}
<div id="delivery-addresses" class="address__list js-address-selector">
{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
}
</div>

{if isset($delivery_address_error)}
<p class="alert alert-danger js-address-error" name="alert-delivery" id="id-failure-address-{$delivery_address_error.id_address}">{$delivery_address_error.exception}</p>
{else}
<p class="h3">{l s='Shipping Address' d='Shop.Theme.Checkout'}</p>
<p class="alert alert-danger js-address-error" name="alert-delivery" style="display: none">{l s='Your address is incomplete, please update it.' d='Shop.Notifications.Error'}</p>
{/if}

{if $show_delivery_address_form}
<div id="delivery-address">
<div class="buttons-wrapper mb-3">
<a href="{$new_address_delivery_url}" class="btn btn-outline-primary">
<i class="material-icons" aria-hidden="true">&#xE145;</i>
{l s='Add new address' d='Shop.Theme.Actions'}
</a>
</div>

{if $use_same_address && !$cart.is_virtual}
<a data-link-action="different-invoice-address" href="{$use_different_address_url}" class="btn btn-basic">
{l s='Billing address differs from shipping address' d='Shop.Theme.Checkout'}
</a>
{/if}
{/if}

{if !$use_same_address}
<p class="h3 mt-4">{l s='Your Invoice Address' d='Shop.Theme.Checkout'}</p>

{if $show_invoice_address_form}
<div id="invoice-address">
{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
}
</div>
{elseif $customer.addresses|count> 0}
<div id="delivery-addresses" class="address__list js-address-selector">
{else}
<div id="invoice-addresses" class="address__list js-address-selector">
{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
}
</div>

{if isset($delivery_address_error)}
<p class="alert alert-danger js-address-error" name="alert-delivery" id="id-failure-address-{$delivery_address_error.id_address}">{$delivery_address_error.exception}</p>
{if isset($invoice_address_error)}
<p class="alert alert-danger js-address-error" name="alert-invoice" id="id-failure-address-{$invoice_address_error.id_address}">{$invoice_address_error.exception}</p>
{else}
<p class="alert alert-danger js-address-error" name="alert-delivery" style="display: none">{l s='Your address is incomplete, please update it.' d='Shop.Notifications.Error'}</p>
<p class="alert alert-danger js-address-error" name="alert-invoice" style="display: none">{l s='Your address is incomplete, please update it.' d='Shop.Notifications.Error'}</p>
{/if}

<div class="buttons-wrapper mb-3">
<a href="{$new_address_delivery_url}" class="btn btn-outline-primary">
<i class="material-icons" aria-hidden="true">&#xE145;</i>
{l s='Add new address' d='Shop.Theme.Actions'}
</a>
</div>

{if $use_same_address && !$cart.is_virtual}
<a data-link-action="different-invoice-address" href="{$use_different_address_url}" class="btn btn-basic">
{l s='Billing address differs from shipping address' d='Shop.Theme.Checkout'}
</a>
{/if}
<a href="{$new_address_invoice_url}" class="btn btn-outline-primary w-100 w-md-auto">
<i class="material-icons" aria-hidden="true">&#xE145;</i>
{l s='Add new address' d='Shop.Theme.Actions'}
</a>
{/if}
{/if}

{if !$use_same_address}
<p class="h3 mt-4">{l s='Your Invoice Address' d='Shop.Theme.Checkout'}</p>

{if $show_invoice_address_form}
<div id="invoice-address">
{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
}
</div>
{else}
<div id="invoice-addresses" class="address__list js-address-selector">
{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
}
</div>

{if isset($invoice_address_error)}
<p class="alert alert-danger js-address-error" name="alert-invoice" id="id-failure-address-{$invoice_address_error.id_address}">{$invoice_address_error.exception}</p>
{else}
<p class="alert alert-danger js-address-error" name="alert-invoice" style="display: none">{l s='Your address is incomplete, please update it.' d='Shop.Notifications.Error'}</p>
{/if}
<div class="buttons-wrapper buttons-wrapper--split buttons-wrapper--invert-mobile mt-3">
{include file='checkout/_partials/checkout-step-buttons.tpl' show_next_button=false}

<a href="{$new_address_invoice_url}" class="btn btn-outline-primary w-100 w-md-auto">
<i class="material-icons" aria-hidden="true">&#xE145;</i>
{l s='Add new address' d='Shop.Theme.Actions'}
</a>
{/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}
<input type="hidden" id="not-valid-addresses" class="js-not-valid-addresses" form="{$addresses_form_id}" value="{$not_valid_addresses}">
{/if}

<div class="buttons-wrapper buttons-wrapper--split buttons-wrapper--invert-mobile mt-3">
{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'}
<input type="hidden" id="not-valid-addresses" class="js-not-valid-addresses" value="{$not_valid_addresses}">
{/if}
</div>
</form>
</div>

{capture name="address_selector_bottom"}{hook h='displayAddressSelectorBottom'}{/capture}
{if $smarty.capture.address_selector_bottom}
Expand Down