Overriding JavaScript
The most straightforward approach to customizing JavaScript in Hyvä is to copy the .phtml template into a custom theme and change the code as needed.
This works, but if the code in the original file changes in a newer Hyvä release, the change will be masked by the template override in the child theme.
Merging changes in JavaScript into overridden templates can be cumbersome and make upgrades more expensive.
There is a better way to customize JavaScript, that allows changes in the default theme to automatically apply to child themes.
The approach is based on the JavaScript language feature allowing functions, methods, and object properties to be redeclared.
Instead of overriding all JavaScript in a template file, it is possible to only override the functions or properties that need to be changed.
To do so, a new template is added to the page that is rendered after the original template containing the JavaScript code to be customized.
Step by step
Follow this recipe when overriding
- Determine the template containing the original JS
- Find the layout XML block declaration for that JS
- Declare a new block that will be rendered after the original
- Create the new template and confirm it is rendered after the original
- Override only the function or method that needs changes while keeping as much as possible of the code in the original file active
Example 1: hyva.formatPrice
One relatively common customization target is the hyva.formatPrice
method.
This method is declared in the template src/view/frontend/templates/page/js/hyva.phtml
of the hyva-themes/magento2-theme-module
module.
It contains many important core functions. Sometimes it is updated in new releases to include bug fixes or new features, so overriding the whole file makes upgrades more expensive.
In this example, we want to customize the method to return FREE!
if the price is zero.
Price formatting: JS & PHP
Please note that in a real project, it would not be enough to customize only the JavaScript hyva.formatPrice
function.
All prices are initially rendered by PHP using the \Magento\Framework\Pricing\PriceCurrencyInterface::format
function.
Customizing the PHP method is outside the scope of this article.
Inject the new template for the override
The block rendering the hyva.phtml
template is declared in the Hyva_Theme
module using the page layout XML file default_hyva.xml
<referenceContainer name="head.additional">
<block name="head.hyva-scripts" template="Hyva_Theme::page/js/hyva.phtml"/>
</referenceContainer>
One possibility would be adding it as a new child of the
head.additional
:
In our theme, in Hyva_Theme/layout/default.xml
, add:
<referenceContainer name="head.additional">
<block name="head.hyva-scripts.format-price-customization"
after="head.hyva-scripts"
template="Hyva_Theme::page/js/format-price-customization.phtml"/>
</referenceContainer>
Alternatively the before.body.end
container would work, too, since it also is rendered after the original template.
Override the code
Then we create the new template and add a script tag.
We have added comments to the code below to explain noteworthy aspects of the customization.
<script>
// To keep the global scope tidy, we wrap everything in an IIFE (Immediately Invoked Function Expression).
// Otherwise, all variables and constants we declare would be globally visible.
(() => {
// Keep a reference to the original method
const origFormatPrice = hyva.formatPrice;
// Redeclare the method we are customizing.
// By using the `function` keyword instead of an arrow function we
// have access to the magic `arguments` variable to capture new
// arguments possibly added in the future.
hyva.formatPrice = function (value, showSign, options = {}) {
// If the value is larger than zero, call the original method
if (value > 0) {
// Call the original method using apply() to ensure all
// arguments are passed along.
return origFormatPrice.apply(null, arguments)
}
// Return custom value if applicable
return '<?= $escaper->escapeJs(__('FREE!')) ?>';
}
})()
</script>
Notes on the example
Calling the original function
It is not always possible to customize a function and still delegate to the original function.
In some cases, the function needs to be completely replaced.
For hyva.formatPrice
this would be the case if the numeric formatting itself should be changed.
Testing the customization
To test the customization, we can call hyva.formatPrice(1)
and hyva.formatPrice(0)
on the browser console.
Example 2: Customize an Alpine component
The previous example customized a simple object method.
However, in Hyvä, many functions return objects to be used as Alpine components.
The names of these functions conventionally start with init*
and are called within an x-data
attribute.
They can be considered constructor functions for Alpine components.
Customizations may need to be applied to methods or properties of the components, so replacing only the constructor function is not sufficient.
Instead, the returned Alpine instance needs to be mutated as shown in this example.
Auto-select single-option attributes
Out of the box, if a configurable product has only a single available option for a variant, customers still need to select it before being able to add the product to the cart.
For this example scenario, we are going to customize the configurable options Alpine.js component to automatically select every configurable attribute with only a single available option.
Step 1: Inject the template for the override
The original template containing the JavaScript we need to customize is Magento_ConfigurableProduct::product/view/type/options/js/configurable-options.phtml
.
The block for the template is declared in the default theme Magento_ConfigurableProduct/layout/catalog_product_view_type_configurable.xml
with:
<referenceContainer name="before.body.end">
<block name="product.info.options.configurable.js"
as="options_configurable_js"
template="Magento_ConfigurableProduct::product/view/type/options/js/configurable-options.phtml"
/>
</referenceContainer>
The override template needs to be rendered later on the page, so we declare it in our theme using the same layout file name. We make it a sibling of the original block by using the same container and add an after
attribute to ensure it renders behind the original.
<referenceContainer name="before.body.end">
<block name="product.info.options.configurable.js-auto-select-single-options"
after="options_configurable_js"
template="Magento_ConfigurableProduct::product/view/type/options/js/auto-select-single-options.phtml"
/>
</referenceContainer>
Step 2: Override the code
Now we can create the auto-select-single-options.phtml
and test it renders as desired.
We have added comments in the code example below to explain noteworthy aspects of the customization.
<script>
// To keep the global scope tidy, we wrap everything in an IIFE (Immediately Invoked Function Expression).
// Otherwise, all variables and constants we declare would be globally visible.
(() => {
// Keep a reference to the original initConfigurableOptions method
const origInitConfigurableOptions = initConfigurableOptions;
// Redeclare the constructor function, so we can customize the return value
// By using the `function` keyword instead of an arrow function we
// have access to the magic `arguments` variable to capture new
// arguments possibly added in the future.
window.initConfigurableOptions = function () {
// Call the original method using apply() to ensure all
// arguments are passed along.
const instance = origInitConfigurableOptions.apply(null, arguments)
// Keep a reference to the original init method
const origInit = instance.init;
// Redeclare the method we are customizing
instance.init = function () {
// Call the original method, using `instance` as the object context
origInit.apply(instance, arguments)
// Add custom logic to auto-select single options
for (const [attributeId, options] of Object.entries(instance.allowedAttributeOptions)) {
if (options.length === 1) {
instance.changeOption(attributeId, Object.values(options)[0].id)
}
}
}
// Return the modified component
return instance;
}
})()
</script>
Notes on the example
Multiple modules customizing the same method
By calling the original method reference, a function or method can be customized by multiple modules without conflict.
JavaScript Proxy
Overriding specific methods can be done using explicit references to the original method, like in the examples above, or with the help of JavaScript Proxies.
Technically both approaches are equivalent - there is nothing one solution allows doing the other doesn't.
For reference, here are versions of the examples above using a Proxy:
Customizing hyva.formatPrice with a Proxy
The following example achieves the same as the code for the first example.
Using a Proxy
may look a lot shorter, but it isn't.
It looks shorter because there are fewer comments.
We don't need to wrap the code in an IIFE since no variable would be available globally.
<script>
hyva.formatPrice = new Proxy(hyva.formatPrice, {
// Intercept method calls
apply(target, thisArg, argArray) {
if (value > 0) {
// Call the original method, passing along any arguments
return target.apply(thisArg, argArray)
}
return '<?= $escaper->escapeJs(__('FREE!')) ?>';
}
})
</script>
Note the apply(target, thisArg, argArray)
method of the Proxy handler is different from the apply
method of Functions.
Customizing an Alpine component with a Proxy
The following code does the same as the second example above:
<script>
window.initConfigurableOptions = new Proxy(initConfigurableOptions, {
// Intercept function calls
apply(target, thisArg, argArray) {
// Call the original constructor function
const instance = target.apply(thisArg, argArray);
instance.init = new Proxy(instance.init, {
// Intercept init method calls
apply(target, thisArg, argArray) {
// Call the original method, passing along any arguments
target.apply(thisArg, argArray);
for (const [attributeId, options] of Object.entries(thisArg.allowedAttributeOptions)) {
if (options.length === 1) {
thisArg.changeOption(attributeId, Object.values(options)[0].id)
}
}
}
})
return instance;
}
})
</script>
Overriding Object Properties
The examples above show how to customize methods.
However, many Alpine components consist not only of methods but also properties.
These can be customized similarly.
For example, the object returned by initConfigurableOptions()
has an itemId
property, which is dynamically set when the instance is created:
In the simplest case, it can be enough to override the constructor function and change the initial value on the instance property before returning the instance.
<script>
(() => {
const origInitConfigurableOptions = initConfigurableOptions;
window.initConfigurableOptions = function () {
const instance = origInitConfigurableOptions.apply(null, arguments)
instance.itemId = 42; // Custom logic to set the property
return instance;
}
})()
</script>
However, if custom logic needs to be involved, either Object.defineProperty
or a Proxy
can be used.
From a technical perspective, both approaches work equally well.
Declaring property accessors with Object.defineProperty
Here is an example of how the itemId
property can be customized using Object.defineProperty
:
<script>
(() => {
const origInitConfigurableOptions = initConfigurableOptions;
let customItemId = 42; // some custom value
window.initConfigurableOptions = function () {
const instance = origInitConfigurableOptions.apply(null, arguments);
// Redeclare itemId property with custom accessor methods
Object.defineProperty(instance, 'itemId', {
get() {
return customItemId;
},
set(value) {
customItemId = value;
}
});
return instance;
}
})()
</script>
Declaring property accessors with a Proxy
Customizing the itemId
property with custom logic using a Proxy
would look something like this:
<script>
(() => {
let customItemId = 42;
window.initConfigurableOptions = new Proxy(initConfigurableOptions, {
// Intercept function calls
apply(target, thisArg, argArray) {
// Call the original constructor and add a Proxy to intercept property access
return new Proxy(target.apply(thisArg, argArray), {
get(target, prop, receiver) {
if (prop === 'itemId') {
return customItemId;
}
return Reflect.get(...arguments);
},
set(target, prop, value, receiver) {
if (prop === 'itemId') {
return (customItemId = value);
}
return Reflect.set(...arguments);
}
})
}
})
})()
</script>