Laravel & Vue: Notificaciones en Tiempo Real usando Reverb, Redis y TDD - 05

José Rafael Gutierrez
hace 20 horas

Capítulo 5: Integración de Reverb en el Frontend con Vue: Recepción de Notificaciones
Objetivo
En este capítulo, configuraremos el frontend en Vue para conectarse a los canales de Reverb y mostrar las notificaciones en tiempo real, siguiendo el enfoque TDD (Test-Driven Development). Específicamente:
- Instalar y configurar Laravel Echo con Reverb en Vue.
- Crear componentes Vue para manejar notificaciones en tiempo real.
- Suscribirse a canales específicos de notificaciones en el frontend.
- Recibir eventos de notificación en tiempo real y actualizar la interfaz de usuario.
Introducción
Hasta ahora, hemos construido un sistema robusto en el backend para generar y transmitir notificaciones usando Reverb, Redis y eventos de Laravel. Ahora es momento de conectar el frontend para que los usuarios puedan recibir estas notificaciones en tiempo real sin necesidad de recargar la página.
Laravel Echo es la librería oficial que Laravel proporciona para integrar fácilmente WebSockets en aplicaciones JavaScript. Con la llegada de Reverb, Echo se ha optimizado para trabajar de manera nativa con esta nueva tecnología, proporcionando una experiencia de desarrollo más fluida y un mejor rendimiento.
En este capítulo, utilizaremos Vue 3 con Composition API para crear componentes reactivos que escuchen los eventos de Reverb y actualicen la interfaz en tiempo real.
Configuración Inicial del Frontend
1. Instalar Dependencias de Frontend
Primero, vamos a instalar las dependencias necesarias para Vue y Laravel Echo:
sail npm install vue@latest @vitejs/plugin-vue laravel-echo pusher-js --save-dev
2. Configurar Vite para Vue
Modifica el archivo vite.config.js
para incluir el plugin de Vue:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue(),
],
server: {
host: '0.0.0.0',
port: 5173,
hmr: {
host: 'localhost',
},
},
});
3. Configurar Laravel Echo en el Frontend
Crea un archivo de configuración para Laravel Echo en resources/js/echo.js
:
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
wssPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
auth: {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
},
},
});
4. Actualizar Variables de Entorno para Frontend
Añade las siguientes variables a tu archivo .env
:
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Ciclo TDD: Creación de Pruebas para el Frontend
1. Configurar Vitest para Pruebas Frontend
Instala las dependencias necesarias para testing:
sail npm install --save-dev vitest @vue/test-utils jsdom
Crea el archivo de configuración vitest.config.js
:
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
resolve: {
alias: {
'@': resolve(__dirname, 'resources/js'),
},
},
});
2. Crear Pruebas para el Componente de Notificaciones
Crea el directorio de pruebas y un test inicial:
mkdir -p resources/js/tests
touch resources/js/tests/NotificationManager.test.js
Modifica resources/js/tests/NotificationManager.test.js
:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import NotificationManager from '@/components/NotificationManager.vue';
// Mock Laravel Echo
const mockEcho = {
private: vi.fn().mockReturnThis(),
listen: vi.fn().mockReturnThis(),
notification: vi.fn().mockReturnThis(),
};
global.Echo = mockEcho;
// Mock para fetch global para evitar peticiones reales durante las pruebas
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ notifications: [], unread_count: 0 })
})
);
describe('NotificationManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should mount successfully', () => {
const wrapper = mount(NotificationManager, {
props: {
userId: 1,
isAdmin: false,
},
});
expect(wrapper.exists()).toBe(true);
});
it('should subscribe to user notification channel on mount', () => {
mount(NotificationManager, {
props: {
userId: 1,
isAdmin: false,
},
});
expect(mockEcho.private).toHaveBeenCalledWith('user-notifications.1');
expect(mockEcho.listen).toHaveBeenCalledWith('order.status.changed', expect.any(Function));
});
it('should subscribe to admin notification channel when user is admin', () => {
mount(NotificationManager, {
props: {
userId: 1,
isAdmin: true,
},
});
expect(mockEcho.private).toHaveBeenCalledWith('admin-notifications');
});
it('should add notification to list when event is received', async () => {
let eventCallback;
mockEcho.listen.mockImplementation((event, callback) => {
eventCallback = callback;
return mockEcho;
});
const wrapper = mount(NotificationManager, {
props: {
userId: 1,
isAdmin: false,
},
});
// Simular recepción de notificación
const mockNotification = {
order_id: 123,
old_status: 'En preparación',
new_status: 'En tránsito',
timestamp: 1634567890,
};
eventCallback(mockNotification);
await wrapper.vm.$nextTick();
expect(wrapper.vm.notifications).toHaveLength(1);
expect(wrapper.vm.notifications[0]).toMatchObject(mockNotification);
});
});
3. Ejecutar la Prueba Inicial (Rojo)
Añade el script de test al package.json
:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}
Ejecuta el test:
sail npm run test:run
Errores esperados:
- Error: Failed to resolve import "@/components/NotificationManager.vue" from "resources/js/tests/NotificationManager.test.js". Does the file exist?
Ciclo TDD: Implementación del Componente de Notificaciones
1. Crear el Componente NotificationManager
Crea el directorio de componentes y el componente:
mkdir -p resources/js/components
touch resources/js/components/NotificationManager.vue
Modifica resources/js/components/NotificationManager.vue
:
<template>
<div class="notification-manager">
<!-- Indicador de notificaciones -->
<div class="notification-badge" v-if="unreadCount > 0">
<span class="badge">{{ unreadCount }}</span>
</div>
<!-- Panel de notificaciones -->
<div class="notification-panel" v-if="showPanel">
<div class="notification-header">
<h3>Notificaciones</h3>
<button @click="markAllAsRead" class="mark-all-read">
Marcar todas como leídas
</button>
</div>
<div class="notification-list">
<div
v-for="notification in displayNotifications"
:key="notification.id"
class="notification-item"
:class="{ 'unread': !notification.read }"
>
<div class="notification-content">
<p class="notification-message">{{ notification.message }}</p>
<span class="notification-time">{{ formatTime(notification.timestamp) }}</span>
</div>
<button
v-if="!notification.read"
@click="markAsRead(notification)"
class="mark-read-btn"
>
✓
</button>
</div>
</div>
<div v-if="displayNotifications.length === 0" class="no-notifications">
No hay notificaciones
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
// Props
const props = defineProps({
userId: {
type: Number,
required: true,
},
isAdmin: {
type: Boolean,
default: false,
},
});
// Estados reactivos
const notifications = ref([]);
const showPanel = ref(false);
const userChannel = ref(null);
const adminChannel = ref(null);
// Estados computados
const unreadCount = computed(() => {
return notifications.value.filter(n => !n.read).length;
});
const displayNotifications = computed(() => {
return notifications.value
.slice()
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 50); // Mostrar solo las últimas 50 notificaciones
});
// Métodos
const subscribeToChannels = () => {
// Suscripción al canal del usuario
userChannel.value = window.Echo.private(`user-notifications.${props.userId}`)
.listen('order.status.changed', (e) => {
addNotification(e);
});
// Suscripción al canal de administradores si es admin
if (props.isAdmin) {
adminChannel.value = window.Echo.private('admin-notifications')
.listen('order.status.changed', (e) => {
addNotification(e);
});
}
};
const addNotification = (notificationData) => {
const notification = {
id: generateId(),
message: `Pedido #${notificationData.order_id}: ${notificationData.old_status} → ${notificationData.new_status}`,
timestamp: notificationData.timestamp,
read: false,
...notificationData,
};
notifications.value.unshift(notification);
// Mostrar notificación del navegador si está disponible
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Nueva notificación', {
body: notification.message,
icon: '/favicon.ico',
});
}
};
const markAsRead = (notification) => {
notification.read = true;
// Aquí podrías enviar una petición al backend para marcar como leída en Redis
markNotificationAsRead(notification.id);
};
const markAllAsRead = () => {
notifications.value.forEach(notification => {
if (!notification.read) {
notification.read = true;
markNotificationAsRead(notification.id);
}
});
};
const markNotificationAsRead = async (notificationId) => {
try {
await fetch('/api/notifications/mark-read', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
},
body: JSON.stringify({
notification_id: notificationId,
user_id: props.userId,
}),
});
} catch (error) {
console.error('Error marking notification as read:', error);
}
};
const formatTime = (timestamp) => {
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'hace un momento';
if (diff < 3600000) return `hace ${Math.floor(diff / 60000)} minutos`;
if (diff < 86400000) return `hace ${Math.floor(diff / 3600000)} horas`;
return date.toLocaleDateString();
};
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
};
const togglePanel = () => {
showPanel.value = !showPanel.value;
};
const requestNotificationPermission = () => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
};
// Lifecycle hooks
onMounted(() => {
subscribeToChannels();
requestNotificationPermission();
loadExistingNotifications();
});
onUnmounted(() => {
if (userChannel.value) {
window.Echo.leaveChannel(`user-notifications.${props.userId}`);
}
if (adminChannel.value) {
window.Echo.leaveChannel('admin-notifications');
}
});
const loadExistingNotifications = async () => {
try {
// Usar URL absoluta o comprobar si estamos en un entorno de pruebas
const baseUrl = typeof window !== 'undefined' && window.location.origin ?
window.location.origin : 'http://localhost';
const response = await fetch(`${baseUrl}/api/notifications/${props.userId}`);
const data = await response.json();
notifications.value = data.notifications || [];
} catch (error) {
console.error('Error loading notifications:', error);
}
};
// Exponer métodos para testing
defineExpose({
notifications,
unreadCount,
addNotification,
markAsRead,
togglePanel,
});
</script>
<style scoped>
.notification-manager {
position: relative;
display: inline-block;
}
.notification-badge {
position: relative;
cursor: pointer;
}
.badge {
position: absolute;
top: -8px;
right: -8px;
background-color: #ef4444;
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 12px;
font-weight: bold;
min-width: 20px;
text-align: center;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.notification-panel {
position: absolute;
top: 100%;
right: 0;
width: 350px;
max-height: 400px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
z-index: 1000;
overflow: hidden;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
}
.notification-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.mark-all-read {
background: #3b82f6;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.mark-all-read:hover {
background: #2563eb;
}
.notification-list {
max-height: 300px;
overflow-y: auto;
}
.notification-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 16px;
border-bottom: 1px solid #f3f4f6;
transition: background-color 0.2s;
}
.notification-item:hover {
background-color: #f9fafb;
}
.notification-item.unread {
background-color: #fef3f2;
border-left: 4px solid #ef4444;
}
.notification-content {
flex: 1;
}
.notification-message {
margin: 0 0 4px 0;
font-size: 14px;
color: #111827;
line-height: 1.5;
}
.notification-time {
font-size: 12px;
color: #6b7280;
}
.mark-read-btn {
background: #10b981;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.mark-read-btn:hover {
background: #059669;
}
.no-notifications {
padding: 32px 16px;
text-align: center;
color: #6b7280;
font-style: italic;
}
</style>
2. Crear Rutas API para Gestión de Notificaciones
Crea el controlador para la API de notificaciones:
sail artisan make:controller Api/NotificationController
Modifica app/Http/Controllers/Api/NotificationController.php
:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Notification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
class NotificationController extends Controller
{
public function index($userId)
{
// Obtener notificaciones de Redis
$redisNotifications = Redis::lrange("notifications:{$userId}", 0, 49);
$notifications = [];
foreach ($redisNotifications as $notification) {
$data = json_decode($notification, true);
$notifications[] = [
'id' => $data['id'] ?? uniqid(),
'message' => $data['message'],
'timestamp' => $data['timestamp'],
'read' => !Redis::sismember("notifications:unread:{$userId}", $data['id'] ?? ''),
];
}
return response()->json([
'notifications' => $notifications,
'unread_count' => Redis::scard("notifications:unread:{$userId}"),
]);
}
public function markAsRead(Request $request)
{
$request->validate([
'notification_id' => 'required|string',
'user_id' => 'required|integer',
]);
$userId = $request->user_id;
$notificationId = $request->notification_id;
// Remover de la lista de no leídas en Redis
Redis::srem("notifications:unread:{$userId}", $notificationId);
return response()->json(['success' => true]);
}
public function markAllAsRead($userId)
{
// Limpiar todas las notificaciones no leídas
Redis::del("notifications:unread:{$userId}");
return response()->json(['success' => true]);
}
}
Instalación de Rutas API para Notificaciones
Ahora vamos a instalar las rutas de API necesarias para gestionar las notificaciones. Para ello, ejecutaremos el siguiente comando:
sail artisan install:api
Añade las rutas en routes/api.php
:
use App\Http\Controllers\Api\NotificationController;
Route::get('/notifications/{userId}', [NotificationController::class, 'index']);
Route::post('/notifications/mark-read', [NotificationController::class, 'markAsRead']);
Route::post('/notifications/{userId}/mark-all-read', [NotificationController::class, 'markAllAsRead']);
3. Configurar la Aplicación Principal de Vue
Modifica resources/js/app.js
:
import './bootstrap';
import { createApp } from 'vue';
import NotificationManager from './components/NotificationManager.vue';
import './echo';
const app = createApp({});
app.component('NotificationManager', NotificationManager);
app.mount('#app');
4. Crear una Vista de Prueba
Crea un archivo blade para probar el componente:
touch resources/views/dashboard.blade.php
Modifica resources/views/dashboard.blade.php
:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Dashboard - Notificaciones</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="antialiased bg-gray-100">
<div id="app">
<nav class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold">Dashboard</h1>
</div>
<div class="flex items-center space-x-4">
<span>Usuario: {{ auth()->user()->name ?? 'Demo' }}</span>
<notification-manager
:user-id="{{ auth()->id() ?? 1 }}"
:is-admin="{{ auth()->user()->is_admin ?? false ? 'true' : 'false' }}"
></notification-manager>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="border-4 border-dashed border-gray-200 rounded-lg h-96 flex items-center justify-center">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">
Sistema de Notificaciones en Tiempo Real
</h2>
<p class="text-gray-600 mb-8">
Las notificaciones aparecerán automáticamente cuando cambien los estados de las órdenes.
</p>
<!-- Botón para simular cambio de estado -->
<button
onclick="simulateOrderChange()"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Simular Cambio de Estado
</button>
</div>
</div>
</div>
</main>
</div>
<script>
function simulateOrderChange() {
fetch('/api/simulate-order-change', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
},
})
.then(response => response.json())
.then(data => {
console.log('Order change simulated:', data);
})
.catch(error => {
console.error('Error:', error);
});
}
</script>
</body>
</html>
5. Crear Ruta para Simular Cambios
Añade una ruta en routes/web.php
para la vista de prueba:
Route::get('/dashboard', function () {
return view('dashboard');
});
Y en routes/api.php
añade una ruta para simular cambios:
Route::post('/simulate-order-change', function () {
$user = \App\Models\User::first() ?? \App\Models\User::factory()->create();
$order = \App\Models\Order::create([
'user_id' => $user->id,
'delivery_status' => 'in_preparation',
]);
// Simular cambio de estado
$order->update(['delivery_status' => 'in_transit']);
return response()->json(['success' => true, 'order_id' => $order->id]);
});
6. Ejecutar las Pruebas (Verde)
Ejecuta las pruebas frontend:
sail npm run test:run
Y las pruebas backend:
sail artisan test
Iniciar el Sistema Completo
1. Inicia Reverb
En una terminal:
sail artisan reverb:start --debug
2. Inicia el Proceso de Colas
En otra terminal:
sail artisan queue:work
3. Inicia el Servidor de Desarrollo Frontend
En otra terminal:
sail npm run dev
4. Prueba la Aplicación
- Visita
http://localhost/dashboard
- Haz clic en "Simular Cambio de Estado"
- Observa cómo aparece la notificación en tiempo real
Depuración de Errores Comunes
1. Las Notificaciones No Aparecen en el Frontend
Si las notificaciones no aparecen:
- Verifica que Reverb esté ejecutándose:
sail artisan reverb:start --debug
- Confirma que las variables de entorno
VITE_*
estén configuradas correctamente - Comprueba la consola del navegador para errores de conexión WebSocket
2. Errores de Autorización en los Canales
Si recibes errores 403:
- Asegúrate de que las rutas de autenticación estén configuradas en
channels.php
- Verifica que el token CSRF esté siendo enviado correctamente
3. Problemas de Conexión WebSocket
Si hay problemas de conexión:
- Verifica que Reverb esté ejecutándose en el puerto correcto
- Confirma que no haya conflictos de firewall o proxy
- Revisa los logs de Reverb para errores de conexión
4. Notificaciones No Se Almacenan en Redis
Si las notificaciones no se almacenan:
- Comprueba que Redis esté ejecutándose:
sail redis-cli ping
- Verifica que el listener esté procesando correctamente los eventos
- Revisa los logs de las colas:
sail artisan queue:work --verbose
Optimizaciones y Mejores Prácticas
1. Limitar el Número de Notificaciones
Para evitar problemas de rendimiento, limita el número de notificaciones mostradas:
const displayNotifications = computed(() => {
return notifications.value
.slice()
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 50); // Solo las últimas 50
});
2. Implementar Debouncing para Múltiples Notificaciones
Si pueden llegar muchas notificaciones rápidamente, implementa debouncing:
import { debounce } from 'lodash-es';
const debouncedAddNotification = debounce(addNotification, 100);
3. Persistir Estado en LocalStorage
Para mantener notificaciones entre recargas de página:
const saveToLocalStorage = () => {
localStorage.setItem('notifications', JSON.stringify(notifications.value));
};
const loadFromLocalStorage = () => {
const stored = localStorage.getItem('notifications');
if (stored) {
notifications.value = JSON.parse(stored);
}
};
4. Implementar Retry Logic para Conexiones Fallidas
Para reconectarse automáticamente si se pierde la conexión:
const setupConnectionRetry = () => {
window.Echo.connector.pusher.connection.bind('state_change', (states) => {
if (states.current === 'disconnected') {
console.log('Connection lost, attempting to reconnect...');
setTimeout(() => {
window.Echo.connector.pusher.connect();
}, 5000);
}
});
};
Conclusión
En este capítulo, hemos implementado con éxito la integración de Reverb en el frontend con Vue, siguiendo el ciclo TDD:
- Configuramos Laravel Echo con Reverb en Vue.
- Creamos un componente reactivo para manejar notificaciones en tiempo real.
- Implementamos la suscripción a canales específicos de notificaciones.
- Desarrollamos la interfaz de usuario para mostrar y gestionar notificaciones.
- Añadimos funcionalidades como marcar como leído y notificaciones del navegador.
En el próximo capítulo, mejoraremos la interfaz de usuario creando componentes más avanzados y optimizando la experiencia del usuario con animaciones y transiciones. 🚀
En esta serie
Capítulo 1: Introducción y Configuración de Reverb y Redis en el Entorno Laravel
Capítulo 2: Creación de Canales de Notificaciones con Reverb en Laravel
Capítulo 3: Configuración de Redis para Almacenar y Administrar Notificaciones
Capítulo 4: Envío de Notificaciones en Laravel con Eventos y Listeners
Capítulo 5: Integración de Reverb en el Frontend con Vue: Recepción de Notificaciones 👈🏽 Estás aquí