Ir al contenido

De publicWidget a Interaction

Cómo Odoo reescribió el JS del portal

Table of contents

No titles in the blog post

Si desarrollas para el portal o el website de Odoo, seguramente escribiste durante años el mismo patrón:

publicWidget.registry.X = publicWidget.Widget.extend({ ... })

jQuery para todo, un objeto events con strings y _super para encadenar el ciclo de vida. Funcionaba, pero arrastraba jQuery y una API que ya se sentía de otra época.

Odoo lo cambió. Desde Odoo 19, el JS público se escribe con Interaction, y publicWidget quedó relegado a @web/legacy. Sigue funcionando, pero ya no es el camino. Según las notas del equipo de Odoo, el plan inicial era convertir los publicWidgets en componentes OWL, pero se descartó: no encajaba y habría impedido servir páginas sin OWL en el futuro (PR #185998). En su lugar nació Interaction: una clase JS estándar, sin jQuery, con contenido dinámico declarativo y limpieza automática.


El caso: un formulario de registro del portal

Al subir uno de mis módulos a Odoo 20 aproveché para sacarlo de publicWidget. Es un formulario de registro real: datos de contacto, tres selects encadenados (país → provincia → ciudad) y la aceptación de la política de privacidad.

Todo el HTML lo pinta QWeb en el servidor. La Interaction solo se engancha a él para hacerlo reaccionar.

Formulario

Estos son los cambios que importan.


1. La definición: de extend a clase nativa

Antes:

import publicWidget from "@web/legacy/js/public/public_widget";

publicWidget.registry.RegistrationForm = publicWidget.Widget.extend({
selector: ".oe_div_registration_form",
start: function () {
return this._super.apply(this, arguments);
},
});

Ahora:

import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";

export class RegistrationForm extends Interaction {
static selector = ".oe_div_registration_form";

setup() {
this.errorMsg = "";
}
}

registry
.category("public.interactions")
.add("modulo.registration_form", RegistrationForm);

Adiós a _super, adiós a extend. Es una clase ES6 normal, se registra en la categoría public.interactions y el selector pasa a ser una propiedad estática.


2. El ciclo de vida cambió de forma

Este punto confunde al principio. start() sigue existiendo, pero ya no significa lo mismo:

  • setup(): inicialización síncrona. Aquí va el estado inicial.
  • willStart(): carga asíncrona antes de arrancar, si la necesitas.
  • start(): síncrono. Corre cuando el contenido dinámico ya está aplicado y los listeners enganchados.
  • destroy(): limpieza. Casi nunca hace falta tocarlo.

Antes metías todo en start y devolvías una promesa. Ahora lo asíncrono va en willStart, y en la mayoría de casos setup es todo lo que necesitas.


3. Eventos: de strings a dynamicContent

Este es el cambio más visible. El viejo objeto events mapeaba strings a nombres de método:

events: {
"submit form": "_onSubmit",
'change select[name="country_id"]': "_onCountryChange",
},

Interaction usa dynamicContent, un objeto declarativo por selector:

dynamicContent = {
"select[name='country_id']": { "t-on-change": this.onCountryChange },
"select[name='state_id']": { "t-on-change": this.onStateChange },
"form.oe_registration_form": { "t-on-submit.prevent.stop": this.onSubmit },
"#errors": {
"t-out": () => this.errorMsg,
"t-att-class": () => ({ "text-danger": !!this.errorMsg }),
},
};

Lo interesante no es que reemplace a events, sino todo lo demás que puede hacer: manejadores de eventos, atributos dinámicos, contenido dinámico y subcomponentes, todo en el mismo sitio.

En el ejemplo, el bloque #errors no escucha nada: enlaza el texto del contenedor a this.errorMsg y su clase a que exista o no un error. Cambias this.errorMsg en un handler y el DOM se actualiza solo. El método viejo que construía un <h5> a mano desaparece.

Además, los qualifiers como .prevent.stop reemplazan las llamadas manuales a preventDefault() y stopPropagation().


4. DOM nativo en vez de jQuery

Todo el this.$(...), .empty() y .append($("<option>")) se va. Poblar un select queda así:

resetSelect(selectEl) {
selectEl.replaceChildren(new Option(_t("Selecciona una opción..."), ""));
}

fillSelect(selectEl, items) {
this.resetSelect(selectEl);
for (const { id, name } of items || []) {
selectEl.append(new Option(name, id));
}
}

new Option(text, value) y replaceChildren() hacen el trabajo sin dependencias.


5. Async y limpieza: el cambio que evita bugs

Este es el punto que más se agradece en producción y el que más fácil se pasa por alto. En el código viejo, un rpc en vuelo seguía tocando el DOM aunque el widget ya no existiera. Interaction trae waitFor:

import { rpc } from "@web/core/network/rpc";

async onCountryChange(ev) {
const items = await this.waitFor(
rpc("/registration/states", { country_id: ev.target.value })
);
this.fillSelect(this.el.querySelector("select[name='state_id']"), items);
}

Si la interaction se destruye mientras la promesa está pendiente, waitFor corta la continuación y no revientas contra un DOM que ya no está. Al destruirse, el framework también revierte solo lo que insertó (listeners, nodos), sin destroy manual.

Un matiz que me costó un rato de "¿por qué no se pinta el error?": el refresco automático de dynamicContent corre al terminar la parte síncrona del handler. Si cambias estado después de un await, tienes que forzar el redibujo:

setError(message){this.errorMsg = message;this.updateContent();// necesario tras un await}


6. Diálogos: del modal de Bootstrap al servicio de OWL

El código viejo inyectaba un template gigante con jQuery y lo mostraba con .modal("show"). Frágil y acoplado a Bootstrap. Ahora se usa el servicio de diálogos:

import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";

showPrivacyDialog() {
this.services.dialog.add(AlertDialog, {
title: _t("Política de privacidad"),
body: _t("Debes aceptar nuestra política de privacidad para continuar."),
confirmLabel: _t("Entendido"),
});
}

Diez líneas de HTML a mano se convierten en una llamada a un servicio. Los servicios (dialog, notification, etc.) están disponibles en this.services.


7. El bonus que nadie pide pero todos necesitan: seguridad

El código viejo pintaba el error interpolándolo en un template:

_errorMessage: function (errorMsg) {
return $(`<div class="alert alert-danger">${errorMsg}</div>`);
}

Si ese errorMsg viene del backend con algo que escribió el usuario, tienes un XSS de manual. La versión declarativa con t-out escapa el contenido por diseño. El cambio de framework te cierra ese agujero casi de regalo.


Lo que aprendí migrando

  • El estado post-await no se refresca solo. updateContent() es obligatorio cuando cambias estado después de un await.
  • Detectar errores por texto se sigue rompiendo. Heredé un message.includes("acepta nuestras pol") del código viejo. En cuanto alguien traduce el mensaje, se rompe. Lo correcto es que el controlador devuelva un código y validar contra eso:
return {"error_code": "privacy"}
if (result.error_code === "privacy") {
this.showPrivacyDialog();
}

El framework nuevo no arregla decisiones de diseño; solo te da mejores herramientas.

El token CSRF. El fetch con FormData solo funciona si el template incluye el token:

<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>

No es del framework, pero es el error clásico al portar estos formularios.

No olvides el manifest. El archivo va en los assets del frontend:

"assets": {
"web.assets_frontend": [
"modulo/static/src/interactions/registration_form.js",
],
},


Conclusión

No es un cambio cosmético. Se pasa de una API basada en jQuery y herencia por extend a clases nativas con un modelo declarativo y un ciclo de vida gestionado. Para un formulario simple parece "lo mismo con otra sintaxis", pero en cuanto entran async, limpieza y diálogos, la versión nueva es claramente más segura y menos propensa a fugas.

Interaction lleva ya una versión entre nosotros. Si en Odoo 20 sigues escribiendo publicWidgets, vas tarde: mejor migrar antes de que el legacy empiece a estorbar.


El ejemplo completo es este:


registration_form_19.js
​ ​ ​ ​ ​ ​ ​ ​ ​ ​
registration_form_20.js



Archivo

Comentarios

Iniciar sesión dejar un comentario
El rediseño de seguridad en Odoo 20
Adiós a la matriz de permisos