Perder el miedo al Checkout #Codehacks
16 May, 2018 / 14 MIN readLlevamos más de dos años trabajando con Magento 2 y parece que aún hoy nos da pánico realizar cambios en el checkout.
Usando proverbios, sólo tememos lo que no conocemos, por eso vamos a intentar poner una base de entendimiento y al menos llegar a un acuerdo de no-agresión con el checkout de magento2, viendo un ejemplo básico de personalización. Todo el código mencionado en este artículo está disponible en github.
Estructura
Se usa básicamente una combinación de require.js y knockout.js (aparte de otros imprescindibles como jQuery, claro) para implementar un patrón model-view-viewmodel, aislando cualquier lógica de vista de cualquier lógica de negocio. De esta forma, a través de unos cuantos (o una infinidad más bien) de pequeños ficheros estáticos (cacheables por completo por cualquier sistema estándar) limitan la interacción con el modelo de magento a unas puntuales llamadas REST. La comunicación entre el viewmodel y magento se realiza de dos formas. De forma estática de magento al viewmodel con una implementación de ConfigProviderInterface, y como hemos mencionado, vía REST del viewmodel hacia Magento.
De esta forma se solventa un gran problema de rendimiento que puede ser el checkout (que no puede ser cacheado), delegando el trabajo duro de renderizado al sistema de caché y al navegador del cliente. Aunque al cliente puede que no le guste, ya sabemos que la velocidad del checkout de esta forma (especialmente durante el desarrollo) dista de ser óptima. Es por eso que ya están muy cerca las aproximaciones PWA como la que nos ofrece DEITY. Pero durante un tiempo seguiremos trabajando con el front y checkout definidos por Magento 2.
Renderizado
Todos (o casi todos) sabemos cómo se estructura el frontend básico en Magento (2). Reduciéndolo a mínimos, tenemos clases Block, Model, Controller y plantillas phtml. Para customizaciones usamos una inyección de dependencias definiéndolas en un di.xml.
Pues en el checkout nos encontramos con una estructura muy similar, en componentes o elementos js. Vemos componentes View, Model, Action y plantillas html. Se definen customizaciones y dependencias en un fichero especial requirejs-config.js.
Obviamente esto es una reducción a mínimos conceptuales. Con un ejemplo de cómo añadir un nuevo template al ckeckout veremos que no es muy diferente a usar blocks y templates como en el front estándar.
Primero añadiremos un nuevo componente a la sección “sidebar”. Para ello crearemos en nuestro módulo el fichero checkout_index_index.xml. (Para saber dónde queremos colocar nuestro nuevo componente previamente nos habremos tenido que familiarizar mínimamente con el layout checkout_index_index.xml original en el módulo Magento_Checkout, con mucha paciencia y mente abierta).
[cc lang="xml"] <!--?xml version="1.0"?--> Misterge_FoolSample/js/view/FoolSample 90 foolsample HEY checkout.steps.billing-step.payment [/cc]
En este ejemplo hemos añadido un nuevo hijo al elemento sidebar. Como se ve, hemos tenido que referenciar toda la estructura padre desde el body, en este layout no funcionará el referenciar elementos ya que la conversión final del layout será un json y no un xml como en el front estándar. A este nuevo componente le hemos establecido el componente en sí Misterge_FoolSample/js/view/FoolSample (que es el equivalente al bloque), con un número de orden, el displayArea en el que lo queremos renderizar, y una configuración básica (un título y un deps en el que le podemos definir en qué paso del checkout lo queremos mostrar, aunque en este ejemplo no lo usaremos).
Veamos el componente que hemos creado en la ruta de nuestro módulo view/frontend/web/js/view/FoolSample.js
[cc lang="js"] define([ 'jquery', 'uiComponent', 'Magento_Checkout/js/model/quote', 'Magento_Checkout/js/model/step-navigator' ], function ($, Component, quote) { 'use strict'; /** * Grabs the grand total from quote * @return {Number} */ var getTotal = function () { return quote.totals()['grand_total']; }; var getQuote = function() { return quote; }; return Component.extend({ defaults: { template: 'Misterge_FoolSample/checkout/foolsample' }, /** * Init component */ initialize: function () { this._super(); this.config = window.checkoutConfig.foolsample; }, getTitle: function () { return this.title; }, getImage: function () { if (getQuote().shippingAddress()) { if (getQuote().shippingAddress().regionCode && getQuote().shippingAddress().regionCode.toLowerCase() === 'albacete') { return require.toUrl(this.config.image2); } } if (getTotal() >= this.config.amount_edge) { return require.toUrl(this.config.image3); } return false; }, isVisible: function () { return (getTotal() >= this.config.minimum_amount); } }) }); [/cc]
A este fichero volveremos repetidamente. De momento sólo nos tenemos que fijar en que le hemos declarado una dependencia a uiComponent (línea 3) y que de hecho la extiende (línea 21). De no ser así, no podremos renderizar nada. Le establecemos una plantilla por defecto en la línea 24.
La plantilla en cuestión, view/frontend/template/checkout/foolsample.html será algo así:
[cc lang="html"] <!-- ko if: (isVisible()) --> <div class="foolSample"> <div class="foolSample-title"></div> <div class="foolSample-content"><!-- ko if: (getImage()) --> <img data-bind="attr: {src: getImage(), alt: $t('Ojocuidao')}"> <!--/ko--></div> </div> <!--/ko--> [/cc]
Aquí (al fin) ya nos encontramos con Knockout.js. Esas etiquetas que a priori parecen comentarios y comienzan con ko (línea 1, por ejemplo) son sentencias que serán interpretadas por el navegador gracias a la librería Knockout.js. Sobra entrar en detalle de la sintaxis (ver en la documentación oficial:
http://knockoutjs.com/documentation/introduction.html ). Aquí nos interesa ver que se referencia a funciones que serán las correspondientes al componente que instancia esta plantilla (en la misma línea 1 se llama a isVisible() y a ello condiciona si el contenido de la etiqueta ko se renderizará o no llamando a la función de la línea 52 del componente view/FoolSample.js). Del mismo modo se pueden vincular atributos como podemos ver en la imagen renderizada por la línea 8 de la plantilla. En ella la ruta src de la imagen dependerá de la función getImage() del componente. La función $t que vemos vinculada al atributo alt es una función global que traduce el parámetro pasado al idioma que se esté renderizando en el navegador del cliente. También traducirá el contenido del span de la línea 4, al establecerle la clave i18n a la salida de la función getTitle().
En principio esto debería bastar para pintar un nuevo bloque en el checkout, pero este ejemplo tiene un truco. Si recordamos, en el fichero checkout_index_index.xml de nuestro módulo le configuramos a nuestro nuevo componente un displayArea personalizado. Pues bien, si buscamos la plantilla original del sidebar (Magento_Checkout/template/sidebar.html) podremos ver que sólo intentará renderizar una region, shipping-information:
[cc lang="html"]<!-- /** * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ --> <div id="opc-sidebar" data-bind="afterRender:setModalElement, mageInit: { 'Magento_Ui/js/modal/modal':{ 'type': 'custom', 'modalClass': 'opc-sidebar opc-summary-wrapper', 'wrapperClass': 'checkout-container', 'parentModalClass': '_has-modal-custom', 'responsive': true, 'responsiveClass': 'custom-slide', 'overlayClass': 'modal-custom-overlay', 'buttons': [] }}"> <!-- ko foreach: getRegion('summary') --> <!-- ko template: getTemplate() --><!-- /ko --> <!--/ko--> <div class="opc-block-shipping-information"><!-- ko foreach: getRegion('shipping-information') --> <!-- ko template: getTemplate() --><!-- /ko --> <!--/ko--></div> </div> [/cc]
Así que tenemos dos opciones, o configurarle a nuestro componente el displayArea shipping-information o cambiamos el sidebar original. Obviamente haremos lo segundo, que para eso estamos haciendo un ejemplo de uso.
Crearemos una nueva plantilla en nuestro módulo, view/frontend/web/template/checkout/sidebar.html:
[cc lang="html"] <div id="opc-sidebar" data-bind="afterRender:setModalElement, mageInit: { 'Magento_Ui/js/modal/modal':{ 'type': 'custom', 'modalClass': 'opc-sidebar opc-summary-wrapper', 'wrapperClass': 'checkout-container', 'parentModalClass': '_has-modal-custom', 'responsive': true, 'responsiveClass': 'custom-slide', 'overlayClass': 'modal-custom-overlay', 'buttons': [] }}"> <!-- ko foreach: getRegion('summary') --> <!-- ko template: getTemplate() --><!-- /ko --> <!--/ko--> <div class="opc-block-shipping-information"><!-- ko foreach: getRegion('shipping-information') --> <!-- ko template: getTemplate() --><!-- /ko --> <!--/ko--></div> <div class="opc-block-foolSample"><!-- ko foreach: getRegion('foolsample') --> <!-- ko template: getTemplate() --><!-- /ko --> <!--/ko--></div> </div> [/cc]
En ella hemos añadido la nueva clase que buscará los elementos configurados en la nueva región o displayArea «foolsample».
Para que Magento reemplace su sidebar por el nuestro, crearemos en nuestro módulo el fichero que hemos mencionado más arriba, en view/frontend/requirejs-config.js
[cc lang="js"] var config = { map: { '*': { "Magento_Checkout/template/sidebar.html": "Misterge_FoolSample/template/checkout/sidebar.html" } } }; [/cc]
Aquí le “mapeamos” a Magento indicándole que cuando algún fichero busque Magento_Checkout/template/sidebar.html tiene que devolver Misterge_FoolSample/template/checkout/sidebar.html
Ahora sí, tenemos un nuevo bloque en el sidebar del checkout. Pero tal y como lo hemos dejado, sigue sin pintar nada…
Configuración
Insertar una plantilla sin más no tiene demasiada utilidad. Al menos vamos a añadirle una configuración que se le pase desde magento y que la renderice.
Magento buscará todas las clases que implementen a Magento\Checkout\Model\ConfigProviderInterface y estén declaradas en Magento\Checkout\Model\CompositeConfigProvider y
las mezclará para darle al checkout toda la configuración necesaria. Por eso crearemos nuestra clase Model/Checkout/BasicConfigProvider.php
[cc lang="php"] <!--?php namespace Misterge\FoolSample\Model\Checkout; use Magento\Checkout\Model\ConfigProviderInterface; /** * Class BasicConfigProvider */ class BasicConfigProvider implements ConfigProviderInterface { const MINIMUM_AMOUNT = 10; const AMOUNT_EDGE = 100; const IMAGE = 'Misterge_FoolSample/images/foolsample2.gif'; const IMAGE2 = 'Misterge_FoolSample/images/foolsample3.gif'; const IMAGER = 'Misterge_FoolSample/images/foolsampler.gif'; /** * {@inheritdoc} */ public function getConfig() { $config = [ 'foolsample' =--> [ 'minimum_amount' => self::MINIMUM_AMOUNT, 'amount_edge' => self::AMOUNT_EDGE, 'image' => self::IMAGE, 'image2' => self::IMAGE2, 'imageR' => self::IMAGER, ] ]; return $config; } } [/cc]
Aquí en la función getConfig() devolveremos las configuraciones que necesitemos, en forma de array, en una clave que crearemos nosotros, o bien en una existente si queremos sobreescribirla (En este caso es importante acordarnos de establecer la secuencia correcta en la declaración del módulo en etc/module.xml). En nuestro ejemplo simplemente añadiremos unos valores constantes, pero sobra decir que aquí podemos utilizar valores de configuración en base de datos, datos del quote actual, o cualquier cosa que nuestra imaginación de back nos permita.
La declararemos en etc/frontend/di.xml:
[cc lang="xml"] <!--?xml version="1.0"?--> Misterge\FoolSample\Model\Checkout\BasicConfigProvider [/cc]
De esta forma tan simple ya tenemos datos dinámicos que pueden ser utilizados desde cualquier elemento del checkout, dentro de la variable global window.checkoutConfig. En nuestro componente de ejemplo se declara en la línea 31.
Ahora sí que veremos un nuevo bloque en el checkout si se cumplen las condiciones “configuradas”.
Modelado
Si usamos un inspector durante el checkout podremos ver que magento básicamente ha elevado al viewmodel todas las entidades básicas. Nos encontramos modelos de customer, customer-address, quote, billing-address, shipping-address, payment-method, etc. Y gracias a requirejs podemos utilizar estas entidades prácticamente desde cualquier sitio.
En nuestro mismo ejemplo, tenemos definidas dos dependencias que no hemos mencionado antes (ver el fichero view/frontend/web/js/view/FoolSample.js líneas 4 y 5), una a la entidad quote y otra al step-navigator, que nos permitiría por ejemplo comprobar en qué paso del checkout estamos, que no usamos en este ejemplo pero hemos dejado para fines instructivos. Vemos que con la sentencia define([], function(){}) establecemos entidades que serán necesarias para ejecutar este componente, y en la función se le asigna la variable global asignada a esas entidades, que podremos usar dentro de nuestro componente. En nuestro ejemplo, elegiremos si mostrar o no el contenido en función del precio total del carrito (línea 53 referenciando a la línea 14), o qué imagen mostrar dependiendo de la provincia de envío (línea 40).
Si buceamos en las entidades usadas, veremos que por ejemplo quote es una entidad que devuelve observables. Esta utilidad de Knockout.js nos permite trabajar siempre con el valor real de una entidad, sin preocuparnos de actualizarla cuando cambie, ya que Knockout se encarga de ello. Cuando un valor de entidades observables cambian, automáticamente todas las funciones que están observando a este valor, cambiarán en consecuencia, sin realizar ninguna acción adicional.
Por ejemplo, en el quote se establece un atributo billingAddress (línea 28 de magento/module-checkout/view/frontend/web/js/model/quote.js):
[cc lang="js"] totals = ko.observable(totalsData), [/cc]
Al que se inicializa como nulo, pero durante la ejecución del checkout se irá actualizando, llamando a (línea 83):
[cc lang="js"] /** * * @return {*} */ getTotals: function () { return totals; }, /** * @param {Object} data */ setTotals: function (data) { data = proceedTotalsData(data); totals(data); this.setCollectedTotals('subtotal_with_discount', parseFloat(data['subtotal_with_discount'])); }, [/cc]
De esta forma en nuestro ejemplo, si se cambia el importe total del carrito (aplicando gastos de envío, o con descuentos, etc,) se cambiará automáticamente el renderizado ya que estamos condicionándolo a quote.totals()
Mixins
Los mixins son una invención de Magento utilizando requirejs, para «simplificar» la extensión o sobreescritura de componentes ya existentes. Son el equivalente a los «plugins» en el backend.
Utilizándolos podemos definir qué elemento o componente queremos extender indicándole el mixin que creamos a ese efecto. Magento extenderá el componente original añadiéndole o reemplazando con lo que tengamos en nuestro mixin. Para ello le añadiremos la sentencia config.mixins a nuestro requirejs-config:
[cc lang="js"]var config = { map: { '*': { "Magento_Checkout/template/sidebar.html": "Misterge_FoolSample/template/checkout/sidebar.html" } }, config: { mixins: { 'Magento_Checkout/js/model/shipping-save-processor/default': { 'Misterge_FoolSample/js/model/shipping-save-processor/default-mixin': true } } } };[/cc]
En este caso modificaremos el funcionamiento de Magento_Checkout/js/model/shipping-save-processor/default . Lo que queremos conseguir es que se setee en el carrito la dirección de facturación por defecto del cliente (algo que en esta versión de Magento no se hace, sino que setea la misma dirección de envío). Así que crearemos nuestro mixin en view/frontend/web/js/model/shippin-save-processor/default-mixin.js:
[cc lang="js"] define([ 'mage/utils/wrapper', 'jquery', 'ko', 'Magento_Checkout/js/model/quote', 'Magento_Checkout/js/action/select-billing-address', 'Misterge_FoolSample/js/action/get-default-billing-address' ], function (wrapper, $, ko, quote, selectBillingAddressAction, getDefaultBillingAddressAction) { 'use strict'; var extender = { saveShippingInformation: function (_super) { if (!quote.billingAddress()) { var defaultBillingAddress; defaultBillingAddress = getDefaultBillingAddressAction(); if (defaultBillingAddress) { selectBillingAddressAction(defaultBillingAddress); } else { selectBillingAddressAction(quote.shippingAddress()); } } return _super(); } }; return function (target) { return wrapper.extend(target, extender); }; }) [/cc]
Vemos aquí que estamos utilizando mage/utils/wrapper para realizar el reemplazo (o extend concretamente) del modelo padre target con nuestro extender. En nuestro extender podemos reemplazar un atributo o función del padre, o añadirle nuevos.
Con este mixin lo que hacemos es reemplazar la función saveShippingInfomation() con una que comprueba si hay dirección de facturación y si no la hay busca la definida por el usuario en sus direcciones como de facturación por defecto. Y después llama a _super() que en es la función padre que estamos sobreescribiendo. Esto sería el equivalente a un plugin “before” en el backend. Para buscar la dirección de facturación por defecto utilizamos una “action” definida en nuestro mismo módulo, en view/frontend/web/js/action/get-default-billing-address.js:
[cc lang="js"] define([ 'jquery', 'Magento_Customer/js/model/customer' ], function ($, customer) { 'use strict'; return function () { var defaultBillingAddress = false; $.each(customer.getBillingAddressList(), function (key, address) { if (address.isDefaultBilling()) { defaultBillingAddress = address; } }); return defaultBillingAddress; }; }) [/cc]
Aquí, como hemos visto en el apartado de modelado, utilizamos la entidad customer que ya tiene declaradas las direcciones, para buscar la que está configurada como facturación por defecto.
A tener en cuenta
- Este ejemplo se ha programado y probado en Magento Community 2.2.3.
- Todos los elementos js y las plantillas deberán estar bajo la ruta frontend/web/ ya que si no no serán accesibles para el navegador.
- Al hacer dependencias y llamadas a otros js, se obvia la extensión .js, ya que se sobreentiende. Si se la añadimos fallará, ya que buscará el fichero *.js.js.
- Al ser toda la comunicación con Magento a través de webapi, las definiciones de los plugins, observers, etc. no funcionarán en el checkout si no están definidas globalmente o para el área webapi_rest. Es decir, definidas en etc/di.xml o en etc/webapi_rest/di.xml
Conclusión
Hemos hecho un recorrido básico para realizar una personalización muy básica en el checkout de Magento2. Hemos podido comprobar la potencia y las debilidades que tiene, y que cumple el dicho de “un front hecho por un back”. Esperamos haber demostrado que no es una caja negra insondable y que aquell@s desarrollador@s que aún no se hayan atrevido, se animen a “jugar” con el checkout de Magento2.
Mientras dure, que la apuesta por el PWA es fuerte y está muy próxima 🙂 Entonces podremos olvidarnos de todo lo dicho en este artículo y gestionar todo el checkout vía webservice. Éste es el punto de vista de un desarrollador sobre todo de backend, claro.