¿Por qué este capítulo? En trabajo real con clientes, rara vez construyes un componente una sola vez y nunca más lo tocas — el siguiente proyecto pide "el mismo modal, pero con un footer distinto" o "la misma lista, pero cada fila necesita una acción personalizada." Los slots son lo que te permite decir que sí sin duplicar el componente. ¿Qué trata de resolver Odoo con esto? La propia UI de Odoo (tarjetas kanban, diálogos, filas de lista) tiene que mantenerse flexible entre modelos y casos de uso muy distintos sin una explosión de widgets casi idénticos. Los slots permiten que un solo componente sea dueño de la estructura mientras quien lo llama es dueño del contenido. Aplicación en la vida real: Un wrapper de diálogo de confirmación reutilizable en una docena de addons para el mismo cliente, o un componente de tabla genérico cuyo renderizado de celdas cambia por vista sin bifurcar la tabla misma.
Hasta ahora, nuestros componentes han sido unidades autocontenidas. Un componente Card siempre tiene un título y contenido. Una PartnerList siempre renderiza una lista de partners en un formato predefinido. Pero, ¿qué pasa si queremos crear componentes flexibles y reutilizables que puedan adaptarse a diferentes casos de uso?
Imagina que estás construyendo un componente genérico Modal. El modal en sí proporciona el overlay de fondo, el contenedor blanco, el posicionamiento y la funcionalidad del botón de cerrar. Pero el contenido dentro del modal—ya sea un mensaje de confirmación, un formulario complejo, una lista de imágenes o un reproductor de video—debería depender completamente del componente padre que lo usa.
Aquí es donde la composición se vuelve esencial, y el mecanismo de OWL para lograr esto son los slots. Los slots son marcadores de posición en la plantilla de un componente hijo que los componentes padre pueden llenar con su propio contenido. Piensa en ellos como "agujeros de contenido" que los padres pueden tapar con markup personalizado, creando infinitas posibilidades desde un solo componente bien diseñado.
Este patrón es fundamental para construir librerías de componentes escalables y se usa extensivamente a lo largo de la interfaz de Odoo.
Entendiendo el Problema: Por Qué Necesitamos Composición
Antes de sumergirnos en slots, entendamos el problema que resuelven con un ejemplo concreto.
El Enfoque Rígido (Sin Slots)
Imagina que creamos un componente ProductCard que muestra información del producto:
// ProductCard.js
import { Component, xml } from "@odoo/owl";
export class ProductCard extends Component {
static template = xml`
<div class="product-card">
<h3 t-esc="props.producto.nombre"/>
<p t-esc="props.producto.descripcion"/>
<div class="precio">$<t t-esc="props.producto.precio"/></div>
<button class="btn btn-primary">Agregar al Carrito</button>
</div>
`;
}
Esto funciona bien para un listado básico de productos. Pero ¿qué pasa cuando necesitas: - Una versión con miniatura de imagen? - Una versión con reseñas de clientes? - Una versión con selectores de cantidad? - Una versión con etiquetas de descuento?
Sin composición, terminarías creando múltiples componentes similares (ProductCardWithImage, ProductCardWithReviews, etc.) o agregando docenas de props condicionales (showImage, showReviews, showQuantity). Ambos enfoques llevan a código rígido y difícil de mantener.
El Enfoque Flexible (Con Slots)
Con slots, creas un solo componente flexible ProductCard que proporciona la estructura y el estilo, mientras permite a los padres inyectar contenido personalizado:
// FlexibleProductCard.js
export class FlexibleProductCard extends Component {
static template = xml`
<div class="product-card">
<t t-slot="header"/>
<h3 t-esc="props.producto.nombre"/>
<t t-slot="content"/>
<div class="precio">$<t t-esc="props.producto.precio"/></div>
<t t-slot="actions"/>
</div>
`;
}
Ahora los padres pueden personalizar cada sección:
<!-- En plantilla del padre -->
<FlexibleProductCard producto="producto">
<t t-set-slot="header">
<img t-att-src="producto.urlImagen" class="producto-miniatura"/>
<span class="badge badge-oferta" t-if="producto.enOferta">¡OFERTA!</span>
</t>
<t t-set-slot="content">
<p t-esc="producto.descripcion"/>
<div class="reseñas">
<t t-foreach="producto.reseñas.slice(0, 3)" t-as="reseña" t-key="reseña.id">
<div class="reseña"><t t-esc="reseña.texto"/></div>
</t>
</div>
</t>
<t t-set-slot="actions">
<input type="number" t-model="state.cantidad" min="1" max="10"/>
<button class="btn btn-primary" t-on-click="agregarAlCarrito">
Agregar <t t-esc="state.cantidad"/> al Carrito
</button>
</t>
</FlexibleProductCard>
Este único componente ahora puede manejar infinitas variaciones sin volverse hinchado o complejo.
El Slot Por Defecto: Tu Primer Paso en la Composición
El caso de uso más común para slots es cuando necesitas un simple marcador de posición de contenido. Esto se maneja con el slot por defecto—un único slot que no necesita ser nombrado.
Creando un Componente Modal
Construyamos un componente reutilizable Modal paso a paso:
1. Definir la Estructura del Modal (modal.xml)
<t t-name="mi_modulo.Modal" owl="1">
<div class="modal-backdrop" t-on-click="cerrarSiClickAfuera">
<div class="modal-dialog" t-on-click.stop="">
<div class="modal-content">
<!-- Encabezado del Modal -->
<div class="modal-header">
<h5 class="modal-title" t-esc="props.titulo or 'Modal'"/>
<button type="button"
class="btn-close"
t-on-click="cerrar"
aria-label="Cerrar">
<span aria-hidden="true">×</span>
</button>
</div>
<!-- Cuerpo del Modal - Aquí va el contenido del padre -->
<div class="modal-body">
<t t-slot="default"/>
</div>
<!-- Pie del Modal (si se proporciona) -->
<div class="modal-footer" t-if="props.slots.footer">
<t t-slot="footer"/>
</div>
</div>
</div>
</div>
</t>
2. La Clase del Componente Modal (modal.js)
import { Component, onWillUnmount } from "@odoo/owl";
export class Modal extends Component {
static template = "mi_modulo.Modal";
setup() {
// Cerrar modal cuando se presiona Escape
this.onKeydown = this.onKeydown.bind(this);
document.addEventListener('keydown', this.onKeydown);
onWillUnmount(() => {
document.removeEventListener('keydown', this.onKeydown);
});
}
onKeydown(event) {
if (event.key === 'Escape') {
this.cerrar();
}
}
cerrar() {
if (this.props.onCerrar) {
this.props.onCerrar();
}
}
cerrarSiClickAfuera(event) {
// Solo cerrar si se hace click en el backdrop, no en el contenido del modal
if (event.target === event.currentTarget) {
this.cerrar();
}
}
}
3. Usando el Modal desde un Componente Padre
<t t-name="mi_modulo.Dashboard" owl="1">
<div class="dashboard">
<h1>Mi Dashboard</h1>
<button class="btn btn-primary" t-on-click="abrirModalConfirmacion">
Eliminar Todos los Datos
</button>
<!-- Modal de Confirmación -->
<t t-if="state.mostrarModalConfirmacion">
<Modal titulo="Confirmar Eliminación" onCerrar="cerrarModalConfirmacion">
<!-- Todo entre estas etiquetas va al slot por defecto -->
<div class="alert alert-danger">
<h4>Advertencia</h4>
<p>Esta acción eliminará permanentemente todos tus datos. Esto no se puede deshacer.</p>
<p>Escribe <strong>ELIMINAR</strong> para confirmar:</p>
<input type="text"
class="form-control"
t-model="state.textoConfirmacion"
placeholder="Escribe ELIMINAR aquí"/>
</div>
<!-- Slot de footer para botones personalizados -->
<t t-set-slot="footer">
<button class="btn btn-secondary" t-on-click="cerrarModalConfirmacion">
Cancelar
</button>
<button class="btn btn-danger"
t-att-disabled="state.textoConfirmacion !== 'ELIMINAR'"
t-on-click="realizarEliminacion">
Eliminar Todo
</button>
</t>
</Modal>
</t>
</div>
</t>
4. La Lógica del Componente Dashboard
import { Component, useState } from "@odoo/owl";
export class Dashboard extends Component {
static template = "mi_modulo.Dashboard";
static components = { Modal };
setup() {
this.state = useState({
mostrarModalConfirmacion: false,
textoConfirmacion: ""
});
}
abrirModalConfirmacion() {
this.state.mostrarModalConfirmacion = true;
this.state.textoConfirmacion = "";
}
cerrarModalConfirmacion() {
this.state.mostrarModalConfirmacion = false;
}
realizarEliminacion() {
if (this.state.textoConfirmacion === 'ELIMINAR') {
// Realizar la eliminación actual
console.log("Eliminando todos los datos...");
this.cerrarModalConfirmacion();
}
}
}
Beneficios Clave de Este Enfoque
1. Reutilización: El mismo componente Modal puede mostrar diálogos de confirmación, formularios, galerías de imágenes, o cualquier otro contenido.
2. Separación de Responsabilidades: El modal maneja posicionamiento, backdrop, eventos de teclado y estilo. El padre maneja el contenido específico y la lógica de negocio.
3. Mantenibilidad: El comportamiento del modal está centralizado. Arreglar un bug o agregar una característica (como animación) beneficia a todos los modales en tu aplicación.
Slots Nombrados: Múltiples Áreas de Contenido
Cuando necesitas más control sobre dónde van diferentes piezas de contenido, los slots nombrados te permiten definir múltiples áreas distintas para inyección de contenido.
Construyendo un Layout Flexible de Página
Creemos un componente PageLayout que proporciona una estructura estándar de página:
1. La Plantilla PageLayout (page_layout.xml)
<t t-name="mi_modulo.PageLayout" owl="1">
<div class="page-container">
<!-- Área de Navegación -->
<nav class="page-navigation" t-if="props.slots.navigation">
<t t-slot="navigation"/>
</nav>
<!-- Sección de Encabezado -->
<header class="page-header">
<t t-slot="header">
<!-- Contenido de encabezado por defecto si no se proporciona -->
<h1 t-esc="props.titulo or 'Título de Página'"/>
</t>
</header>
<!-- Barra Lateral (si se proporciona) -->
<aside class="page-sidebar" t-if="props.slots.sidebar">
<t t-slot="sidebar"/>
</aside>
<!-- Área de Contenido Principal -->
<main class="page-content" t-att-class="{ 'with-sidebar': props.slots.sidebar }">
<t t-slot="default">
<!-- Contenido por defecto si no se proporciona -->
<p>No se proporcionó contenido.</p>
</t>
</main>
<!-- Barra de Acciones -->
<div class="page-actions" t-if="props.slots.actions">
<t t-slot="actions"/>
</div>
<!-- Pie de Página -->
<footer class="page-footer" t-if="props.slots.footer">
<t t-slot="footer"/>
</footer>
</div>
</t>
2. Usando Slots Nombrados desde Múltiples Padres
Ejemplo 1: Una Página de Configuración
<PageLayout titulo="Configuración de Usuario">
<t t-set-slot="navigation">
<ul class="nav-menu">
<li><a href="#perfil">Perfil</a></li>
<li><a href="#seguridad">Seguridad</a></li>
<li><a href="#notificaciones">Notificaciones</a></li>
</ul>
</t>
<t t-set-slot="sidebar">
<div class="settings-sidebar">
<h3>Configuración Rápida</h3>
<label>
<input type="checkbox" t-model="state.modoOscuro"/>
Modo Oscuro
</label>
<label>
<input type="checkbox" t-model="state.notificaciones"/>
Notificaciones por Email
</label>
</div>
</t>
<!-- El contenido principal va al slot por defecto -->
<div class="settings-content">
<h2>Configuración de Perfil</h2>
<form t-on-submit.prevent="guardarConfiguracion">
<div class="form-group">
<label>Nombre Completo</label>
<input type="text" t-model="state.usuario.nombre" class="form-control"/>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" t-model="state.usuario.email" class="form-control"/>
</div>
</form>
</div>
<t t-set-slot="actions">
<button class="btn btn-secondary" t-on-click="reiniciarConfiguracion">Reiniciar</button>
<button class="btn btn-primary" t-on-click="guardarConfiguracion">Guardar Cambios</button>
</t>
</PageLayout>
Ejemplo 2: Una Página de Catálogo de Productos
<PageLayout titulo="Catálogo de Productos">
<t t-set-slot="header">
<div class="catalog-header">
<h1>Nuestros Productos</h1>
<div class="search-bar">
<input type="text"
placeholder="Buscar productos..."
t-model="state.consultaBusqueda"
class="form-control"/>
</div>
</div>
</t>
<t t-set-slot="sidebar">
<div class="filtros">
<h3>Filtros</h3>
<div class="filter-group">
<label>Categoría</label>
<select t-model="state.categoriaSeleccionada" class="form-control">
<option value="">Todas las Categorías</option>
<t t-foreach="state.categorias" t-as="categoria" t-key="categoria.id">
<option t-att-value="categoria.id" t-esc="categoria.nombre"/>
</t>
</select>
</div>
<div class="filter-group">
<label>Rango de Precio</label>
<input type="range" t-model="state.precioMaximo" min="0" max="1000"/>
<span>Hasta $<t t-esc="state.precioMaximo"/></span>
</div>
</div>
</t>
<!-- Cuadrícula de productos en contenido principal -->
<div class="product-grid">
<t t-foreach="productosFiltrados" t-as="producto" t-key="producto.id">
<ProductCard producto="producto"/>
</t>
</div>
<t t-set-slot="footer">
<div class="paginacion">
<button t-att-disabled="state.paginaActual === 1"
t-on-click="paginaAnterior">
Anterior
</button>
<span>Página <t t-esc="state.paginaActual"/> de <t t-esc="state.totalPaginas"/></span>
<button t-att-disabled="state.paginaActual === state.totalPaginas"
t-on-click="paginaSiguiente">
Siguiente
</button>
</div>
</t>
</PageLayout>
Entendiendo la Detección de Slots
Nota el patrón t-if="props.slots.sidebar" en nuestra plantilla. OWL automáticamente proporciona un objeto props.slots que te dice qué slots ha llenado el padre:
<!-- Solo renderizar el contenedor sidebar si el padre proporcionó contenido sidebar -->
<aside class="page-sidebar" t-if="props.slots.sidebar">
<t t-slot="sidebar"/>
</aside>
<!-- Aplicar clase CSS diferente basada en si existe el sidebar -->
<main class="page-content" t-att-class="{ 'with-sidebar': props.slots.sidebar }">
<t t-slot="default"/>
</main>
Esto permite a tu componente adaptar su layout dinámicamente basado en qué contenido proporciona el padre.
Slots con Ámbito: Compartiendo Datos Entre Hijo y Padre
El patrón de slot más avanzado son los slots con ámbito, donde el componente hijo pasa datos al contenido del slot del padre. Esto permite componentes increíblemente flexibles que manejan lógica de datos mientras dan a los padres control completo sobre la presentación.
Construyendo una Tabla de Datos Genérica
Creemos un componente DataTable que maneja ordenamiento, filtrado y paginación, pero permite a los padres personalizar completamente cómo se renderiza cada fila:
1. El Componente DataTable (data_table.js)
import { Component, useState } from "@odoo/owl";
export class DataTable extends Component {
static template = "mi_modulo.DataTable";
setup() {
this.state = useState({
campoOrden: null,
direccionOrden: 'asc',
paginaActual: 1,
tamañoPagina: this.props.tamañoPagina || 10,
consultaBusqueda: ""
});
}
get datosFiltrados() {
let datos = this.props.datos || [];
// Aplicar filtro de búsqueda
if (this.state.consultaBusqueda) {
const consulta = this.state.consultaBusqueda.toLowerCase();
datos = datos.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(consulta)
)
);
}
// Aplicar ordenamiento
if (this.state.campoOrden) {
datos = [...datos].sort((a, b) => {
const aVal = a[this.state.campoOrden];
const bVal = b[this.state.campoOrden];
if (aVal < bVal) return this.state.direccionOrden === 'asc' ? -1 : 1;
if (aVal > bVal) return this.state.direccionOrden === 'asc' ? 1 : -1;
return 0;
});
}
return datos;
}
get datosPaginados() {
const inicio = (this.state.paginaActual - 1) * this.state.tamañoPagina;
const fin = inicio + this.state.tamañoPagina;
return this.datosFiltrados.slice(inicio, fin);
}
get totalPaginas() {
return Math.ceil(this.datosFiltrados.length / this.state.tamañoPagina);
}
ordenarPor(campo) {
if (this.state.campoOrden === campo) {
this.state.direccionOrden = this.state.direccionOrden === 'asc' ? 'desc' : 'asc';
} else {
this.state.campoOrden = campo;
this.state.direccionOrden = 'asc';
}
this.state.paginaActual = 1; // Reiniciar a la primera página al ordenar
}
paginaSiguiente() {
if (this.state.paginaActual < this.totalPaginas) {
this.state.paginaActual++;
}
}
paginaAnterior() {
if (this.state.paginaActual > 1) {
this.state.paginaActual--;
}
}
}
2. La Plantilla DataTable (data_table.xml)
<t t-name="mi_modulo.DataTable" owl="1">
<div class="data-table-container">
<!-- Barra de Búsqueda -->
<div class="table-controls">
<input type="text"
class="form-control search-input"
placeholder="Buscar..."
t-model="state.consultaBusqueda"/>
</div>
<!-- Encabezado de Tabla (si se proporciona) -->
<div class="table-header" t-if="props.slots.header">
<t t-slot="header"
ordenarPor="ordenarPor"
campoOrden="state.campoOrden"
direccionOrden="state.direccionOrden"/>
</div>
<!-- Cuerpo de Tabla -->
<div class="table-body">
<t t-if="datosPaginados.length === 0">
<div class="empty-state">
<t t-slot="empty">
<p>No hay datos disponibles</p>
</t>
</div>
</t>
<t t-else="">
<t t-foreach="datosPaginados" t-as="item" t-key="item.id">
<!-- Pasar datos del item y utilidades al slot del padre -->
<t t-slot="row"
item="item"
indice="item_index"
esPar="item_index % 2 === 0"/>
</t>
</t>
</div>
<!-- Paginación -->
<div class="table-pagination" t-if="totalPaginas > 1">
<button class="btn btn-sm btn-secondary"
t-att-disabled="state.paginaActual === 1"
t-on-click="paginaAnterior">
Anterior
</button>
<span class="pagination-info">
Página <t t-esc="state.paginaActual"/> de <t t-esc="totalPaginas"/>
(<t t-esc="datosFiltrados.length"/> elementos totales)
</span>
<button class="btn btn-sm btn-secondary"
t-att-disabled="state.paginaActual === totalPaginas"
t-on-click="paginaSiguiente">
Siguiente
</button>
</div>
</div>
</t>
3. Usando la Tabla de Datos con Ámbito
Ahora los padres pueden usar esta tabla poderosa mientras tienen control completo sobre cómo se muestran los datos:
<!-- En un componente padre -->
<DataTable datos="state.clientes" tamañoPagina="5">
<!-- Encabezado personalizado ordenable -->
<t t-set-slot="header" t-slot-scope="headerProps">
<div class="table-header-row">
<div class="header-cell sortable"
t-on-click="() => headerProps.ordenarPor('nombre')"
t-att-class="{
'sort-asc': headerProps.campoOrden === 'nombre' && headerProps.direccionOrden === 'asc',
'sort-desc': headerProps.campoOrden === 'nombre' && headerProps.direccionOrden === 'desc'
}">
Nombre del Cliente
</div>
<div class="header-cell sortable"
t-on-click="() => headerProps.ordenarPor('email')">
Email
</div>
<div class="header-cell sortable"
t-on-click="() => headerProps.ordenarPor('totalOrdenes')">
Total de Órdenes
</div>
<div class="header-cell">Acciones</div>
</div>
</t>
<!-- Renderizado personalizado de fila -->
<t t-set-slot="row" t-slot-scope="rowProps">
<div class="table-row" t-att-class="{ 'even': rowProps.esPar }">
<div class="cell">
<img t-att-src="rowProps.item.avatar" class="avatar-sm"/>
<strong t-esc="rowProps.item.nombre"/>
<span t-if="rowProps.item.esVip" class="badge badge-gold">VIP</span>
</div>
<div class="cell">
<a t-att-href="`mailto:${rowProps.item.email}`" t-esc="rowProps.item.email"/>
</div>
<div class="cell">
<span class="order-count" t-esc="rowProps.item.totalOrdenes"/>
<small t-if="rowProps.item.fechaUltimaOrden">
(Última: <t t-esc="formatearFecha(rowProps.item.fechaUltimaOrden)"/>)
</small>
</div>
<div class="cell actions">
<button class="btn btn-sm btn-primary"
t-on-click="() => this.editarCliente(rowProps.item)">
Editar
</button>
<button class="btn btn-sm btn-danger"
t-on-click="() => this.eliminarCliente(rowProps.item)">
Eliminar
</button>
</div>
</div>
</t>
<!-- Estado vacío personalizado -->
<t t-set-slot="empty">
<div class="text-center p-4">
<h3>No se encontraron clientes</h3>
<p>Intenta ajustar tus criterios de búsqueda o agrega tu primer cliente.</p>
<button class="btn btn-primary" t-on-click="abrirModalAgregarCliente">
Agregar Primer Cliente
</button>
</div>
</t>
</DataTable>
Entendiendo las Props de Slots con Ámbito
La clave de los slots con ámbito es la directiva t-slot-scope en <t t-set-slot>:
<!-- El hijo pasa datos como atributos en la llamada a t-slot -->
<t t-slot="row"
item="item"
indice="item_index"
esPar="item_index % 2 === 0"/>
<!-- El padre nombra el objeto de ámbito con t-slot-scope y recibe todo en él -->
<t t-set-slot="row" t-slot-scope="rowProps">
<!-- rowProps contiene: { item: {...}, indice: 0, esPar: true } -->
<div>Nombre del item: <t t-esc="rowProps.item.nombre"/></div>
</t>
Este patrón permite al componente hijo proporcionar tanto datos como funciones de utilidad a la lógica de renderizado del padre.
Renderizando Fuera del Árbol: t-portal
Los slots dejan que un padre controle qué renderiza un hijo. t-portal resuelve un problema distinto: deja que un componente renderice dónde en el DOM real aparece su contenido, independientemente de dónde esté ese componente en el árbol de componentes de OWL.
¿Por qué querrías eso? Piensa en un diálogo modal o un tooltip disparado desde muy adentro de una página—digamos, desde una fila de una tabla dentro de una tarjeta dentro de una barra lateral. Si el modal se renderiza normalmente, hereda cualquier overflow: hidden o contexto de apilamiento z-index que sus ancestros hayan definido, lo que puede recortarlo o enterrarlo detrás de otro contenido. La solución que los desarrolladores web usan desde hace años es renderizar el DOM de ese modal como hijo directo de <body>, evitando el estilo de todos sus ancestros—mientras sigue siendo un componente OWL normal, totalmente reactivo, lógicamente propiedad de su padre.
t-portal hace exactamente eso: la posición lógica del componente en el árbol (props, estado, ciclo de vida) no cambia, solo dónde termina su DOM.
class Tooltip extends Component {
static template = xml`
<span t-ref="anchor" t-on-mouseenter="() => state.visible = true">
<t t-esc="props.label"/>
</span>
<div t-if="state.visible" t-portal="'body'" class="tooltip-content">
<t t-esc="props.text"/>
</div>`;
setup() {
this.state = useState({ visible: false });
}
}
El atributo t-portal recibe un selector CSS, como string, indicando dónde debe adjuntarse el contenido—aquí, 'body'. OWL inserta un nodo marcador vacío en la ubicación original del tooltip para llevar la cuenta internamente, pero el elemento .tooltip-content real se agrega a <body>, libre de cualquier recorte o problema de apilamiento de sus ancestros lógicos.
Recurre a t-portal específicamente para modales, tooltips y menús desplegables—contenido que necesita escapar visualmente del layout de su padre mientras sigue siendo, desde el punto de vista de OWL, una parte normal del componente que lo creó.
Mejores Prácticas para Arquitectura Basada en Slots
1. Diseña Slots con Propósito
Cada slot debe tener una responsabilidad clara y única:
- header: Títulos de página, navegación, barras de búsqueda
- actions: Botones, controles, menús de acción
- footer: Paginación, totales, texto de ayuda
- default: Área de contenido principal
Evita slots genéricos como slot1, slot2 que no transmiten significado.
2. Proporciona Valores Por Defecto Sensatos
Siempre proporciona contenido por defecto para slots cuando tenga sentido:
<t t-slot="header">
<!-- Encabezado por defecto si el padre no proporciona uno -->
<h1 t-esc="props.titulo or 'Página Sin Título'"/>
</t>
Esto hace que tu componente funcione inmediatamente mientras sigue siendo personalizable.
3. Documenta tu API de Slots
Al crear componentes reutilizables, documenta qué slots están disponibles y qué props proporcionan los slots con ámbito:
/**
* Componente DataTable
*
* Slots:
* - header: Encabezados de columna de tabla (props: { ordenarPor, campoOrden, direccionOrden })
* - row: Renderizado de fila individual (props: { item, indice, esPar })
* - empty: Se muestra cuando no hay datos disponibles
*
* Props:
* - datos: Array de objetos para mostrar
* - tamañoPagina: Número de elementos por página (por defecto: 10)
*/
export class DataTable extends Component {
// ...
}
4. Usa Renderizado Condicional de Slots
Adapta el layout de tu componente basado en qué slots se proporcionan:
<!-- Ajustar el ancho del contenido principal basado en la presencia del sidebar -->
<main t-att-class="{
'col-12': !props.slots.sidebar,
'col-9': props.slots.sidebar
}">
<t t-slot="default"/>
</main>
5. Combina Slots con Props para Máxima Flexibilidad
Algunas áreas de contenido podrían ser lo suficientemente simples para props, mientras otras necesitan el poder completo de los slots:
// Texto simple puede ser una prop
static props = {
titulo: { type: String, optional: true },
subtitulo: { type: String, optional: true },
// Contenido complejo usa slots
};
<div class="card">
<!-- Contenido simple via props -->
<h3 t-if="props.titulo" t-esc="props.titulo"/>
<p t-if="props.subtitulo" t-esc="props.subtitulo"/>
<!-- Contenido complejo via slots -->
<t t-slot="default"/>
</div>
Errores Comunes y Cómo Evitarlos
1. Uso Excesivo de Slots
No todo necesita ser un slot. Si el contenido raramente cambia, una prop podría ser más simple:
// En lugar de esto...
<Modal>
<t t-set-slot="titulo">¿Guardar Cambios?</t>
</Modal>
// Considera esto...
<Modal titulo="¿Guardar Cambios?">
2. Olvidar Manejar Slots Vacíos
Siempre verifica si un slot tiene contenido antes de renderizar su contenedor:
<!-- Bueno: Solo renderizar footer si se proporciona contenido -->
<footer t-if="props.slots.footer" class="modal-footer">
<t t-slot="footer"/>
</footer>
<!-- Malo: div de footer vacío si no hay contenido -->
<footer class="modal-footer">
<t t-slot="footer"/>
</footer>
3. Lógica Compleja en Plantillas
Mantén la lógica de slots simple. Mueve computaciones complejas a getters o métodos:
// Bueno: Lógica en componente
get deberiaMostrarSidebar() {
return this.props.slots.sidebar && !this.props.ocultarSidebar;
}
<!-- Plantilla simple -->
<aside t-if="deberiaMostrarSidebar">
<t t-slot="sidebar"/>
</aside>
Dominar slots transforma cómo piensas sobre el diseño de componentes. En lugar de crear componentes rígidos de un solo propósito, construyes fundamentos flexibles que se adaptan a infinitos casos de uso. Este enfoque es esencial para construir aplicaciones Odoo mantenibles y escalables y se usa a lo largo del código base de Odoo para máxima reutilización y personalización.
En el próximo capítulo, exploraremos cómo gestionar estado que necesita ser compartido entre componentes que no están directamente relacionados en el árbol de componentes.
TL;DR: Los slots dejan que un padre inyecte contenido (por defecto, nombrado o scoped) dentro de la estructura de un hijo, así construyes un componente flexible en vez de muchos rígidos y casi idénticos.
Pruébalo tú mismo: El ejemplo de este capítulo está disponible como addon instalable de Odoo 19: simplifyit_owl_book_ch14_ex1. Las instrucciones de instalación están en el README del repositorio.
Ejercicios
- Modal con dos slots. Construye un componente
Modalcon un slot por defecto para el cuerpo y un slot nombradofooter. Renderízalo una vez solo con cuerpo, y otra vez con cuerpo y footer, confirmando que el área del footer desaparece limpiamente cuando no se usa (ver "Olvidar Manejar Slots Vacíos" arriba). - Práctica de scoped slot. Extiende el
DataTablede este capítulo para que el slot del padre reciba no solo elrecordde la fila, sino también suindex. Úsalo en el template del padre para renderizar colores alternados por fila. - Slot vs. prop. Toma un componente que hayas construido con un slot y pregúntate: ¿este contenido podría ser en cambio una prop simple? Reescríbelo como prop si es así, y escribe una oración sobre cuál versión es más fácil de usar desde el lado del padre.
¿Qué Sigue?
Ya cubrimos cómo fluye el contenido entre componentes directamente relacionados como padre e hijo. El Capítulo 15 aborda el caso más difícil: estado compartido entre componentes que no están relacionados en absoluto dentro del árbol, usando un store global.