Proyecto completo en Javascript#
Una vez vistos todos los contenidos de la parte de Javascript, estamos listos para hacer un proyecto entero. No nos detendremos a explicar el porqué de los pasos a seguir, tan solo a hacer referencia a los capítulos anteriores.
El orden en que se realizan los pasos de este proyecto puede ser alterado. De hecho, el orden es un poco arbitrario porque es el que podríamos seguir en clase, no en un proyecto real. En clase se puede ir mejorando incrementalmente y hacer y deshacer. En un proyecto real comenzaríamos instalando todas las librerías, configurando los tests, linters, hooks, github actions y el despliegue automatizado antes de hacer la primera línea de código para ser más eficientes. También empezaríamos directamente con programación funcional y reactiva. Pero puede que todo ese entorno resulte demasiado denso para el alumnado que empieza. Así que comenzaremos con una configuración mínima y refactorizaremos en algunas ocasiones.
Resumen de los pasos#
Es imposible establecer una guía universal para hacer proyectos. Pero estos pasos en este orden puede ser un buen punto de partida para no olvidar nada.
Crear un repositorio GIT y un proyecto en alguna herramienta de gestión de proyectos como
Trello
oGithub Projects
.Crear una carpeta local y dentro clonar o configurar el repositorio.
Suponiendo que ya tenemos un análisis de requisitos y un diseño de la base de datos: Configurar el backend, en nuestro caso
Supabase
. Crear todas las tablas y algunas RLS básicas.Crear un proyecto
Vite
y con la configuración requerida.Instalar
Vitest
y configurarlo.Configurar
Vercel
para probar la puesta en producción.Configurar el repositorio, por ejemplo en
Github
conVercel
para comenzar con la integración continua desde el primer momento. (Se puede retrasar a cuando ya tengamos una primera versión)Configurar
Eslint
,Prettier
yHusky
junto aVitest
para respetar el estilo y los tests en cada commit.Configurar una herramienta de integración continua como
Github Action
para testar en cadaPull Request
.Comenzar a escribir los
springs
comoissues
del proyecto, en el caso deGithub
. De esta manera el equipo puede repartirse el trabajo, asignarse tareas y crear una rama para cada tarea.En este momento se empieza a programar. Podemos seguir este orden, en casa paso hay que escribir los test primero, opcionalmente siguiendo la metodología
TDD
:Creación de la definición de los modelos, es decir, estructuras de datos que representen los datos de la base de datos, las filas y las columnas. (En typescript las
Interfaces
)Creación de las utilidades de comunicación con el backend y autenticación.
Creación del HTML principal y de los componentes que tienen HTML casi estático como los menús, pies o la página inicial.
Creación del
Router
y los componentes que representarán a las páginas.Creación de los componentes que serán las vistas de la aplicación.
Creación de un gestor del estado de la aplicación. En nuestro caso podemos crear servicios que ‘inyectaremos’ en los componentes y usando
Subjects
de RxJS.Empezar a relacionar las vistas con los datos del servidor. Para ello se desarrollarán controladores que relacionarán las rutas y los eventos con peticiones al servidor y posterior actualización de las vistas.
Antes o después se puede implementar la reactividad. Las vistas se suscriben a los servicios mediante
Observables
y se actualizan bidireccionalmente.Implementar la lógica de la aplicación en los servicios.
Configuración inicial#
En realidad, para empezar a hacer algo en Javascript en el frontend, tan solo necesitamos un archivo HTML y uno JS. Se puede empezar por ahí e ir incorporando las distintas librerias, bundlers y demás. También se puede empezar con el esqueleto del CI/CD completo y luego empezar a programar. Empezaremos de una manera “mixta”, configurando Vite
y los archivos y servicios que nos afectan a la programación desde el principio e iremos instalando y configurando conforme sea necesario.
Vite#
Ejecutamos los comandos básicos:
npm create vite@latest my-app -- --template vanilla
cd my-app
npm install
npm run dev
Con esto ya tenemos una plantilla con la que comenzar. Eliminaremos el código de ejemplo, las imágenes y el css de ejemplo para empezar desde cero. Este puede ser un buen momento para instalar algun framework de estilos como Bootstrap
. En nuesto caso, como no nos interesa tanto la parte visual, vamos a instalarlo y así simplificamos ese apartado:
https://getbootstrap.com/docs/5.2/getting-started/vite/
Podemos crear una estructura html mínima para el index.html
a partir de la plantilla proporcionada por Vite:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app">
<div id="menu"></div>
<div id="container" class="container"></div>
</div>
<script type="module" src="/main.js"></script>
</body>
</html>
Puesto que ya tenemos Bootstrap
, crearemos el menú más simple, un navbar
en views/views.js:
const buildMenu = () => {
const divWrapper = document.createElement("div");
const menu = `<nav class="navbar navbar-expand-lg bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">Movies 2024</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Dropdown
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link disabled">Disabled</a>
</li>
</ul>
<form class="d-flex" role="search">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav>`;
divWrapper.innerHTML = menu;
return divWrapper.querySelector("nav");
};
y en main.js:
import "./styles.scss";
//import * as bootstrap from "bootstrap";
import { buildMenu } from "./views/views";
document.addEventListener("DOMContentLoaded", async () => {
const menuDiv = document.querySelector("#menu");
const containerDiv = document.querySelector("#container");
containerDiv.innerHTML = "";
menuDiv.append(buildMenu());
});
Vitest#
Durante el proyecto vamos a seguir una metodología próxima a TDD. Por tanto, desde el principio necesitamos instalar los test.
npm install -D vitest
Debemos crear archivos de prueba que tengan la extensión .test.js
.
En package.json:
{
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
}
}
npm run test
npm run coverage
npx vitest --ui
En el artículo de tests tenemos lo anterior explicado con detalle.
Estructura del proyecto#
El proyecto va a ser muy básico, así que no necesitamos una gran arquitectura. Separaremos la conexión al backend de la vista y los controladores. Por tanto, inicialmente podemos crear carpetas para modelos
o servicios
y para las vistas
. Los modelos se conectarán a la base de datos y servirán los datos de forma reactiva, es decir, con RxJS. Las vistas crearán elementos del DOM
mediante template literals y serán reactivas. Haremos un CRUD con autenticación en el que el backend será Supabase
.
Backend básico con Supabase#
Nos damos de alta en Supabase y creamos un proyecto nuevo. Una vez creado, creamos las tablas de la base de datos y ponemos algunos datos. En nuestro caso, he creado inicialmente una tabla movies
con el contenido de una base de datos de unas 45000 películas de la IMDB. Probamos el comando que nos recomienda para ver las películas:
curl 'https://ygvtpucoxveebizknhat.supabase.co/rest/v1/movies?select=*' \
-H "apikey: SUPABASE_KEY" \
-H "Authorization: Bearer SUPABASE_KEY"
Lo podemos probar también en Postman. Esta información proporcionada por el API Docs
de Supabase será la base para una función inicial de obtención de las películas:
const getSupabase = async (table) => {
try {
let response = await fetch(
`https://ygvtpucoxveebizknhat.supabase.co/rest/v1/${table}?select=*`,
{
headers: {
apikey,
Authorization: `Bearer ${apikey}`,
},
},
);
if (!response.ok) {
return Promise.reject("Bad request");
}
return response;
} catch {
return Promise.reject("Network Error");
}
};
const getData = (response) => {
return response.json();
};
Como esta función, podemos ir haciendo las demás.
Veamos un objeto de los que retorna esta petición:
{
"adult": false,
"belongs_to_collection": "Toy Story Collection",
"budget": "30000000",
"original_language": "en",
"original_title": "Toy Story",
"overview": "Led by Woody, Andy's toys live happily in his room until Andy's birthday brings Buzz Lightyear onto the scene. Afraid of losing his place in Andy's heart, Woody plots against Buzz. But when circumstances separate Buzz and Woody from their owner, the duo eventually learns to put aside their differences.",
"popularity": 21.946943,
"release_date": "1995-10-30",
"revenue": "373554033.0",
"runtime": "81.0",
"tagline": "not available",
"title": "Toy Story",
"vote_average": "7.7",
"vote_count": "5415.0",
"languages": "['English']",
"day_of_week": "Monday",
"month": "Oct",
"season": "Q4",
"year": "1995",
"has_homepage": "YES",
"genre": "['Animation', 'Comedy', 'Family']",
"companies": "['Pixar Animation Studios']",
"countries": "['United States of America']",
"id": "8554eb64-1588-4480-84aa-ab1796b1707b"
}
Como se puede ver, es un objeto que tiene un “id” generado automáticamente por Supabase que es lo que lo identifica en la base de datos. Uno de los problemas que tiene es que el género, compañías, idiomas y países son “arrays”, pero tratados por Supabase como Strings. Por tanto, si queremos interpretarlos hay que parsearlos. Por lo demás no parece tener más problemas.
Los Arrays entre comillas que nos retorna el servidor son una buena ocasión para practicar TDD. Este es el test:
describe("stringToArray", async () => {
test("stringToArray should return an Array of movies", () => {
let complexArray = `['Procirep', 'Constellation Productions', 'France 3 Cinéma', 'Claudie Ossard Productions', 'Eurimages', 'MEDIA Programme of the European Union', 'Cofimage 5', 'Televisión Española (TVE)', 'Tele München Fernseh Produktionsgesellschaft (TMG)', "Club d'Investissement Média", 'Canal+ España', 'Elías Querejeta Producciones Cinematográficas S.L.', 'Centre National de la Cinématographie (CNC)', 'Victoires Productions', 'Constellation', 'Lumière Pictures', 'Canal+', 'Studio Image', 'Cofimage 4', 'Ossane', 'Phoenix Images']`;
let result = _views.stringToArray(complexArray);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBe(21);
expect(result[9]).toBe(`Club d'Investissement Média`); // El complicado por la '
});
});
Observemos que "Club d'Investissement Média"
tiene comillas dobles y una comilla simple en medio. Esto complica el JSON.parse.
Y la función resultante:
const stringToArray = (string) => string
.split(',')
.map(S => S.replace(/[\\[\]"]/g, '')
.replace(/^[ ']+/g, '')
.replace(/'$/g, ''));
Puesto que tenemos funciones genéricas para obtener datos y funciones específicas de las películas para transformar los datos, es buena idea separarlas. Por eso, podemos crear un fichero para el modelo
“movies” y otro para las peticiones http. El primero tendrá esas funciones para transformar las películas en un array válido. Por ejemplo:
const parseMovies = (movies) => {
let moviesCopy = structuredClone(movies);
moviesCopy.forEach(m => {
m.genre = stringToArray(m.genre);
m.companies = stringToArray(m.companies);
m.countries = stringToArray(m.countries);
});
return moviesCopy;
}
Cuyo test es:
test("parseMovies should return an Array of movies with arrays parsed", () => {
let result = _movies.parseMovies(exampleMovies);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBe(4);
expect(result.every(m=> m.genre instanceof Array)).toBe(true);
expect(result.every(m=> m.companies instanceof Array)).toBe(true);
expect(result.every(m=> m.countries instanceof Array)).toBe(true);
});
Luego, para ver las películas de forma básica en la vista, gracias a Bootstrap
podemos hacer una lista desplegable:
const buildMoviesComponent = (movies) => {
const divWrapper = document.createElement("div");
divWrapper.classList.add("accordion");
divWrapper.innerHTML = movies
.map(
(m, index) => `<div class="accordion-item">
<h2 class="accordion-header" id="panelsStayOpen-heading${index}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#panelsStayOpen-collapse${index}" aria-expanded="true" aria-controls="panelsStayOpen-collapse${index}">
${m.original_title}
</button>
</h2>
<div id="panelsStayOpen-collapse${index}" class="accordion-collapse collapse" aria-labelledby="panelsStayOpen-heading${index}">
<div class="accordion-body">
<h3>Overview</h3>
${m.overview}
<h3>Technical Data:</h3>
<ul class="list-group">
<li class="list-group-item">Release Date: ${m.release_date}</li>
<li class="list-group-item">Revenue: ${m.revenue}</li>
<li class="list-group-item">Runtime: ${m.runtime}</li>
<li class="list-group-item">Tagline: ${m.tagline}</li>
<li class="list-group-item">Vote Average: ${m.vote_average}</li>
<li class="list-group-item">Vote Count: ${m.vote_count}</li>
<li class="list-group-item">Year: ${m.year}</li>
</ul>
<h3>Genres</h3>
<div class="btn-group" role="group" aria-label="Basic example">
${m.genre
.map(
(g) =>
`<button type="button" class="btn btn-primary" data-genre="${g}">${g}</button>`
)
.join("")}
</div>
<h3>Companies</h3>
<div class="btn-group" role="group" aria-label="Basic example">
${m.companies
.map(
(g) =>
`<button type="button" class="btn btn-primary" data-company="${g}">${g}</button>`
)
.join("")}
</div>
<h3>Countries</h3>
<div class="btn-group" role="group" aria-label="Basic example">
${m.countries
.map(
(g) =>
`<button type="button" class="btn btn-primary" data-country="${g}">${g}</button>`
)
.join("")}
</div>
</div>
</div>
</div>`
)
.join("");
return divWrapper;
};
En el ejemplo anterior usamos un template literal
para cada película i dentro usamos más para los botones. De momento no le hemos dado funcionalidad a los botones.
Búsqueda en el backend#
Ahora que ya tenemos la capacidad de descargar cosas del backend, vamos a implementar una búsqueda para ver las posibilidades.
Cuando un usuario pulse uno de los botones de, por ejemplo, el género de una película, se debería buscar en Supabase todas las películas de ese género. Puesto que se trata de una base de datos PostgreSQL, esto será traducido finalmente a una consulta SQL. Pero Supabase proporciona una interfaz API REST para hacer esta petición de forma remota. Tengamos en cuenta también que el género se almacena en una string, aunque parezca un array.
Para hacer una búsqueda en Supabase usando el API REST, seguiremos la siguente sintaxis:
products?select=*&asin=like.*AX*
Como se ve, products
es la tabla, después va select=*
para indicar que necesitamos todas las columnas y a continuación la condición de búsqueda. Imaginemos que en la tabla movies
necesitamos las películas del género action
.
https://ygvtpucoxveebizknhat.supabase.co/rest/v1/movies?genre=like.*Action*&select=*&
Por tanto, hay que construir la petición. Esto va a cambiar las funciones que hemos creado antes para poder hacerlas más genéricas y reutilizar código.
Este es el cambio en la petición:
const getSupabase = async (table, columns, search) => {
try {
let response = await fetch(
`https://ygvtpucoxveebizknhat.supabase.co/rest/v1/${table}?select=${columns ? columns : '*'}${search ? `&${search}` : ''}`,
{
....
La búsqueda cambiará la lista de películas descargadas que se visualizan. Para volver atrás, una buena manera es implementar un router:
Router#
Este sería el main.js
aplicando un router:
const fillElement = (container) => (content) => { container.innerHTML = ""; container.append(content); }
const router = async (route, container) => {
const fillContainer = fillElement(container);
// Rutas con expresiones regulares
if (/#\/movies\/genre\/.+/.test(route)) {
let genreID = route.split("/")[3];
let movies = await getMovies(`genre=ilike.*${genreID}*`);
fillContainer(buildMoviesComponent(movies));
}
// Rutas a páginas específicas
else {
switch (route) {
case "#/":
{ let movies = await getMovies();
fillContainer(buildMoviesComponent(movies));
break; }
case "#/movies":
{
let movies = await getMovies();
fillContainer(buildMoviesComponent(movies));
break; }
// Añadir más rutas según sea necesario
default:
console.log('404 page not found');
}
}
};
document.addEventListener("DOMContentLoaded", async () => {
const menuDiv = document.querySelector("#menu");
menuDiv.append(buildMenu());
const containerDiv = document.querySelector("#container");
window.location.hash = "#/";
router(window.location.hash, containerDiv);
window.addEventListener("hashchange", () => {
router(window.location.hash, containerDiv);
});
});
Ahora tocaría añadir el resto de rutas para otras búsquedas sobre paises o compañias. También hay que crear el enlace en los botones. Así en la vista, añadimos los eventos a los botones:
divWrapper.querySelectorAll('button[data-genre]').forEach(button => button.addEventListener('click',()=>{
window.location.hash = `#/movies/genre/${button.dataset.genre}`;
}));
CI/CD#
Este paso lo podríamos haber hecho al principio, pero ahora ya tenemos algo mínimo funcional y seguramente tenemos clara la arquitectura que tendrá i los tests iniciales.
La metodología de integración continua nos dice que podemos ir sacando versiones del producto incrementalmente. Ya tenemos la primera versión que funciona, así que vamos a implementar algunas herramientas para facilitar el despliegue sin errores.
Linter#
Instalamos Eslint
según el manual de este mismo libro y lo configuramos como un Hook
con Husky
.
npm install eslint --save-dev
npm init @eslint/config@latest
npm install --save-dev husky
npx husky init
Prettier#
Igualmente, instalamos Prettier:
npm install --save-dev --save-exact prettier
Y ahora ponemos los dos scripts en el pre-commit
de Husky
:
npx eslint
npx prettier . --write
npm run testrun
En este caso testrun está configurado en el package.json
como:
"testrun": "vitest run",
Todo esto evitará que se haga un commit si no pasa el Linter o los tests y mejorará el estilo del código con Prettier.
Vercel#
Como se explica en el manual de este mismo libro, hay que crear un proyecto en Vercel
, vincularlo con un Deploy
y configurar que se quede a la espera de cambios en el proyecto.
Programación funcional#
Desde el principio ya hemos aplicado algunos conceptos de programación funcional. Siempre hay que hacer funciones puras y evitar las mutaciones. Pero ahora podemos usar los conceptos de curring
y composición
.
Veamos un ejemplo. La siguiente función es una buena candidata a ser compuesta:
const getMovies = async (search) => {
const movies = parseMovies(await getData(await getSupabase("movies","*",search)));
return movies;
}
Antes de nada, hacemos un fichero de utilidades de composición. De momento tiene un objeto literal que actúa de espacio de nombres
con una colección de funciones. Usaremos _
imitando a la librería Lodash
.
export const _ = {
compose : (...fns) => x => fns.reduceRight((v, f) => f(v), x),
asyncCompose : (...fns) => x => fns.reduceRight(async(v, f) => f(await v), x)
}
Las funciones anteriores se pueden componer de manera que la salida de una sea la entrada de la otra:
getSupabase -> getData -> parseMovies
Pero hay un problema y es que getSupabase no es una función unaria
y no acepta un solo argumento. El último argumento que acepta es la variable search
. Para solucionar ese problema, las librerías de programación funcional tienen funciones que currifican funciones. Como no es algo que haremos muchas veces, podemos poner una función flecha que arregle el problema. De esta manera, usando asyncCompose
, queda:
const getMovies = async (search) => _.asyncCompose(
parseMovies,
getData,
(s)=> getSupabase("movies","*",s)
)(search);
Incluso se puede simplificar, ya que la salida de una función de composición es una función. Así no hace ni falta explicitar que acepta search
:
const getMovies = _.asyncCompose(
parseMovies,
getData,
search => getSupabase("movies","*",search)
);
Usar la función de composición sólo para ese caso puede ser sobreingeniería, pero veamos otras partes del código que han mejorado con ella:
// Original:
const parseMovies = (movies) => {
let moviesCopy = structuredClone(movies);
moviesCopy.forEach(m => {
m.genre = stringToArray(m.genre);
m.companies = stringToArray(m.companies);
m.countries = stringToArray(m.countries);
});
return moviesCopy;
}
// Más funcional:
const curriedMap = (func) => (array) => array.map(func);
const parseArrays = (movie) => {
const movieCopy = structuredClone(movie);
['genre', 'companies', 'countries']
.forEach(a => movieCopy[a] = stringToArray(movieCopy[a]));
return movieCopy;
}
const parseMovies = _.compose(
curriedMap(parseArrays),
);
Ahora tenemos dos funciones más, curriedMap
se podría volver a utilizar, así que la dejaremos en el espacio de nombres. La de parseArrays
está muy relacionada con las películas, así que la dejaremos ahí.
Ahora es criterio del programador decidir si esta programación es más conveniente. En esta guía no llegaremos mucho más lejos en cuanto a los criterios de la programación funcional, pero usaremos esas técnicas en adelante siempre que se pueda y mejore el código.
Programación reactiva#
Como pasaba con el CI/CD, la programación reactiva se debe aplicar desde el inicio en un proyecto nuevo si se decide usarla. No obstante, la refactorización también es un buen ejercicio para entenderla.
Vamos a aplicarla en dos fases. La primera de ellas será refactorizar las funciones de comunicación con Supabase para convertirlas en funciones que manejan Observables. También implementaremos reactivamente el buscador de películas. A continuación nos plantearemos cómo gestional el estado de la aplicación.
Comunicación reactiva con el servidor#
De momento, el router actúa de controlador
, pidiendo al modelo
los datos y pasándolos a las funciones que crean la vista. Esto es válido si hay una única petición y con esos datos se debe renderizar. Si hay más peticiones la cosa se puede complicar, por eso vamos a repensar toda la arquitectura cambiando lo mínimo para trabajar con Observables
, de manera que, cuando lleguen las peticiones, se actualice la vista reactivamente.
Empezaremos instalado RxJS:
npm install rxjs
Podemos dejar las funciones básicas de http
para que sigan trabajando con promesas, pero las funciones del modelo
las podemos refactorizar. Así queda getMovies
:
const getMovies = (search) => from(getSupabase("movies", "*", search))
.pipe(
switchMap(getData),
map(parseMovies),
);
Así queda la versión simple del router para cuando pide las 1000 primeras:
...
switch (route) {
case "#/":
getMovies().subscribe(movies => {
fillContainer(buildMoviesComponent(movies));
});
break;
...
No obstante, no estamos aprovechando la potencia real de los Observables, ya que estamos creando una subscripción cada vez que se cambia la ruta. Además, esto tiene un peligro potencial, y es que cada subscripción no elimina la anterior. Lo que podemos hacer es un Subject
que usaremos para no crear nuevas subscripciones.
const moviesSubject = new Subject();
const getMovies = (search) => {
let subscription = from(getSupabase("movies", "*", search))
.pipe(
switchMap(getData),
map(parseMovies),
).subscribe(movies => { moviesSubject.next(movies); subscription.unsubscribe()});
}
Así creamos un Subject que nos servirá en un futuro. Veamos cómo nada más obtener las películas, nos desuscribimos del Observable de la petición. Por tanto, esta parte del código no dejará suscripciones en memoria.
En el programa principal hemos creado una variable global para todas las subscripciones y la desuscribimos cada vez que cambia la ruta para volver a suscribirse con la nueva petición.
let subscription = null;
const router = async (route, container) => {
if (subscription) {
subscription.unsubscribe();
}
const fillContainer = fillElement(container);
// Rutas con expresiones regulares
if (/#\/movies\/genre\/.+/.test(route)) {
let genreID = route.split("/")[3];
getMovies(`genre=ilike.*${genreID}*`)
subscription = moviesSubject.subscribe(movies => {
fillContainer(buildMoviesComponent(movies));
});
}
....
Hasta ahora, hemos tenido que instalar una librería para hacer lo mismo con más código. Pero ahora está preparado para aprovechar la potencia de los observables.
Buscador con Observables#
En el navbar de Bootstrap hay un input y un botón de buscar. Vamos a darle funcionalidad.
fromEvent(divWrapper.querySelector('#searchInput'),'keyup').pipe(
map((event) => event.target.value),
tap(text => console.log(text)),
distinctUntilChanged(), //Para evitar keyups en teclas que no cambian el value
debounceTime(300) // Para no saturar la búsqueda en Supabase
).subscribe(searchText => getMovies(`title=ilike.*${searchText}*`));
Tan solo con este código hemos echo un buscador que no satura al servidor, no provoca refresco de la página y aprovecha el Subject que ya teníamos hecho anteriormente.
Gestión del estado#
En el caso de esta aplicación, el estado actual no es muy complejo. Por un lado tenemos la ruta que marca el filtro por género, país o compañía. También está el Subject declarado como variable global para estar accesible a todas las rutas y luego está el filtro de búsqueda.
Si sólo hemos hecho el código en el órden de esta guía, hay un conflicto entre la ruta y la búsqueda, ya que no se acumulan. Si buscamos, invalidamos la ruta. Además, estamos limitando el uso del input a películas. Si tenemos luego otras cosas en la interfaz, no las podemos usar.
Así que podríamos tener una variable global que indicara todos criterios de búsqueda para cuando se hacen las peticiones.
Vamos a aprovechar que tenemos Subjects para gestionar el estado.
En una aplicación real más sofisticada, podriamos usar
Redux
.
Declaramos una variable state
que es un BehaviorSubject
. Lo podemos hacer en un fichero a parte al que damos acceso a todos los que tengan que manipular el estado:
export const state = new BehaviorSubject({
search: '',
route: {}
});
Despues, podemos centralizar las peticiones al servidor escuchando los cambios en el estado:
let stateSubscription = state.subscribe(currentState => {
getMovies(`${ 'criteria' in currentState.route ? `${currentState.route.criteria}=ilike.*${currentState.route.value}*` : '' }${currentState.search}`);
});
Las rutas ya no hacen las peticiones, sino que cambian el estado:
...
switch (route) {
case "#/":
state.next({search: '', route: {}});
subscription = moviesSubject.subscribe((movies) => {
fillContainer(buildMoviesComponent(movies));
});
...
Y el buscador también:
fromEvent(divWrapper.querySelector("#searchInput"), "keyup")
.pipe(
map((event) => event.target.value),
tap((text) => console.log(text)),
distinctUntilChanged(), //Para evitar keyups en teclas que no cambian el value
debounceTime(300), // Para no saturar la búsqueda en Supabase
)
.subscribe((searchText) => state.next({search: searchText.length > 0 ? `&title=ilike.*${searchText}*` : '', route: {... state.getValue().route}}));
Además, hemos añadido un breadcrumb
de Bootstrap al contenedor:
`<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">Movies</li>
${'criteria' in state.getValue().route ? `<li class="breadcrumb-item active" aria-current="page">${state.getValue().route.value}</li>` : '<li class="breadcrumb-item active" aria-current="page">All</li>' }
${state.getValue().search != '' ? `<li class="breadcrumb-item active" aria-current="page">${state.getValue().search}</li>` : '' }
</ol>
</nav>`
Esta aplicación tiene una gestión del estado a medias entre la url y un Subject. Más adelante también mantendrá un estado permanente con
localstorage
para gestionar ellogin
.
Todos estos cambios suponen también cambios en los tests. Con la metodología TDD se ha pasado de esperar que las funciones retornen Promesas a Observables y hay que testar que los observables funcionan.
Observemos un ejemplo de test de un Observable:
test("getMovies should return an Observable that returns an array of parsed movies", async () => {
_movies.getMovies();
expect(_movies.moviesSubject).toBeInstanceOf(Observable);
_movies.moviesSubject.subscribe(movies => {
expect(movies).toBeInstanceOf(Array);
expect(movies.length).toBe(4);
expect(movies[0].genre).toBeInstanceOf(Array);
});
});
Hemos cambiado la Promesa del test anterior por un observable.
Test Coverage#
Si hacemos un test al coverage
nos dirá cuánto código hay testado y la parte del código que no pasa por ningún test.
npm run coverage
...
% Coverage report from v8
-----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files | 55.16 | 80.76 | 63.63 | 55.16 |
projecte | 0 | 0 | 0 | 0 |
main.js | 0 | 0 | 0 | 0 | 1-69
projecte/models | 89.23 | 83.33 | 80 | 89.23 |
http.js | 100 | 75 | 100 | 100 | 9
movies.js | 78.78 | 100 | 66.66 | 78.78 | 27-33
state.js | 100 | 100 | 100 | 100 |
projecte/utils | 90.9 | 100 | 66.66 | 90.9 |
functionals.js | 90.9 | 100 | 66.66 | 90.9 | 8
projecte/views | 63.97 | 75 | 50 | 63.97 |
views.js | 63.97 | 75 | 50 | 63.97 | 8-55,132
-----------------|---------|----------|---------|---------|-------------------
También genera una web en la carpeta coverage
donde ver exáctamente las partes del código que fallan.
Podemos observar que nos faltan todos los tests del main
. Esto es normal, ya que suele tener las funciones del programa principal, que no son puras ni simples. Otros tienen una mayor coverage, aunque no el 100%.
No es preciso tener un 100% de covertura en los tests, a veces es imposible. No obstante, puede ser útil para determinar qué partes del código se nos han podido despistar. Depende del equipo de trabajo, puede ser una medida de calidad y se puede considerar que un código está cubierto a partir de cierto porcentaje.
Autenticación#
Puesto que nuestro proyecto tiene como Backend Supabase, vamos a ver cómo autenticar. Vamos a implementar la autenticación mediante usuario/contraseña.
Para implementar la autenticación en Supabase hay que seguir unos pasos prévios:
Habilitar o asegurarse de que está habilitada la autenticación por contraseña.
Ir a la parte de
SQL Editor
y ejecutar elQuickstart
User Management Starter
. Esto no es preciso para la autenticación, pero prepara la base de datos para mantener los perfiles de los usuarios y sus avatares. Si lo hacemos desde el principio, todos los usuarios funcionarán correctamente.Ir al
API Docs
y mirar cómo se hace la autenticación porBash
.Crear funciones para autenticar y registrar usuarios con el método descrito en el paso anterior.
Crear formularios para llamar a esas funciones.
Guardar el
token
después de la autenticación enLocalstorage
.Modificar las funciones ya creadas para usar el
token
en cada petición.Modificar las
RLS
de las tablas para protegerlas de las peticiones que no tengan el token.Aplicar funciones para gestionar la duración de las sesiones, el logout…
Gestionar los perfiles de usuario como una tabla más llamada
profiles
y un bucketavatars
creados automáticamente con elUser Management Starter
.
Así quedan una posible función de registro:
Y un ejemplo de cómo puede ser un test de esa función. No necesitamos testar si el servidor funciona bien. Sólo si hacemos la petición correcta:
describe("user management", async () => {
const responseOk = {"id":"8662025a-25be-4777-ae54-66cb6e58929e","aud":"authenticated","role":"authenticated","email":"test@gmail.com","phone":"","confirmation_sent_at":"2024-06-28T14:39:09.806587958Z","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"email":"test@gmail.com","email_verified":false,"phone_verified":false,"sub":"8662025a-25be-4777-ae54-66cb6e58929e"},"identities":[{"identity_id":"209fd965-9ea1-4b16-8e1d-4ce200dfd400","id":"8662025a-25be-4777-ae54-66cb6e58929e","user_id":"8662025a-25be-4777-ae54-66cb6e58929e","identity_data":{"email":"test@gmail.com","email_verified":false,"phone_verified":false,"sub":"8662025a-25be-4777-ae54-66cb6e58929e"},"provider":"email","last_sign_in_at":"2024-06-28T14:39:09.786271233Z","created_at":"2024-06-28T14:39:09.786321Z","updated_at":"2024-06-28T14:39:09.786321Z","email":"test@gmail.com"}],"created_at":"2024-06-28T14:39:09.756356Z","updated_at":"2024-06-28T14:39:11.379383Z","is_anonymous":false};
const server = setupServer(
http.post(
"https://ygvtpucoxveebizknhat.supabase.co/auth/v1/signup",
// eslint-disable-next-line
async ({request}) => {
let peticion = await request.text();
console.log(peticion);
if (peticion == `{"email":"test@gmail.com","password":"passwd"}`){
return HttpResponse.json(responseOk);
}
return HttpResponse.json([`mal`]);
},
),
);
beforeAll(() => {
server.listen({
onUnhandledRequest(request) {
console.log('Unhandled %s %s', request.method, request.url)
},
});
});
afterAll(() => server.close());
test("signup should send a valid json with user data", async () => {
let signupPromise = await _http.signup("test@gmail.com","passwd");
let data = await _http.getData(signupPromise);
expect(data).toEqual(responseOk);
});
});
});
Modificamos las peticiones para incluir el token:
...
let response = await fetch(
`https://ygvtpucoxveebizknhat.supabase.co/rest/v1/${table}?select=${columns ? columns : "*"}${search ? `&${search}` : ""}`,
{
headers: {
apikey,
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
},
},
...
Esto hace uso de localStorage, así que hay que hacer un mock
del mismo:
beforeAll(() => {
server.listen({
onUnhandledRequest: "bypass",
});
localStorage.setItem('access_token', "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlndnRwdWNveHZlZWJpemtuaGF0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTUzNTA0ODYsImV4cCI6MjAzMDkyNjQ4Nn0.rDdWANw1LN10BunTH8TKeIAfM-EMlWpTaNyxQSbe30k");
});
Esto funciona porque estamos usando @vitest-environment jsdom
. Pero dejará de funcionar en cuanto apliquemos las RLS
. Como veremos a continuación, hay que hacer ‘login’ antes de los tests.
Estas son las RLS
aplicadas a la tabla movies
:
create policy "Enable read access for authenticated"
on "public"."movies"
as PERMISSIVE
for SELECT
to authenticated
using (
true
);
Los tests no fallan porque Supabase falla silenciosamente para no dar pistas, pero retorna un JSON con este contenido: []
. Si añadimos este test:
test("getSupabase should return some movies", async () => {
let getSupabasePromise = _http.getSupabase("movies");
let response = await getSupabasePromise;
let data = await _http.getData(response);
expect(response.status).toBe(200);
expect(response.statusText).toBe("OK");
expect(data.length).toBeGreaterThan(0);
});
Veremos que falla. Hagamos login antes de hacer los test:
beforeAll(async () => {
server.listen({
onUnhandledRequest: "bypass",
});
(await _http.login(env.EMAIL, env.PASSWORD));
});
env
es una variable importada deenvironment.js
, un fichero que debe estar en.gitignore
. De hecho, vamos a aprovechar para poner elapi-key
y la `urlBase también en env.
Para no alargar demasiado el manual, no vamos a implementar todo lo que se debería. Pero a partir de aquí todo se soluciona con observables, y localStorage. Falta detectar en toda la web si está autenticado y avisar si no lo está. Falta hacer que no sea necesario hacer login si ya se guarda la sesión en localStorage. Falta implementar el logout, entre otras cosas.
Perfiles#
Los usuarios, al ser creados, invocan en Supabase una acción que crea también una fila en la tabla profiles
. Todo esto es así si hemos ejecutado el SQL de User Management Starter
.
Si queremos tratar con su avatar, hay que implementar funciones para obtener y subir imágenes:
async function fileRequest(url, body) {
const headersFile = {
apikey,
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
'x-upsert': true, // Necessari per a sobreescriure
};
const response = await fetch(`${urlBase}${url}`, {
method: 'POST',
headers: headersFile,
body,
});
if (response.status >= 200 && response.status <= 300) {
if (response.headers.get('content-type')) {
const datos = await response.json();
datos.urlAvatar = `${urlBase}${url}`;
return datos;
}
return {};
}
return Promise.reject(await response.json());
}
async function getFileRequest(url) {
const headersFile = {
apikey,
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
};
const response = await fetch(`${url}`, {
method: 'GET',
headers: headersFile,
});
if (response.status >= 200 && response.status <= 300) {
if (response.headers.get('content-type')) {
const datos = await response.blob();
return datos;
}
return {};
}
return Promise.reject(await response.json());
}
También para conseguir esa imagen del profile y el resto de datos:
const getProfile = () => {
// Obtiene el token de acceso del localStorage
const access_token = localStorage.getItem('access_token');
// Obtiene el UID del localStorage
const uid = localStorage.getItem('uid');
// Convierte la promesa devuelta por getSupabase en un observable
return from(getSupabase("profiles", "*", `&id=eq.${uid}`))
.pipe(
// Usa switchMap para aplanar el observable y cambiar a un nuevo observable basado en la respuesta
switchMap(getData),
// Usa switchMap para manejar el procesamiento del perfil y la carga del avatar
switchMap((dataProfile) => {
// Extrae la URL del avatar del primer objeto del perfil
const { avatar_url } = dataProfile[0];
// Inicializa avatar_blob a false
dataProfile[0].avatar_blob = false;
// Si existe una URL de avatar
return avatar_url ?
// Convierte la promesa de getFileRequest en un observable
from(getFileRequest(avatar_url, access_token)).pipe(
// Usa map para transformar el resultado del observable
map(avatarBlob => {
// Si el avatarBlob es una instancia de Blob, crea una URL para el objeto y asigna a avatar_blob
dataProfile[0].avatar_blob = avatarBlob instanceof Blob ? URL.createObjectURL(avatarBlob) : '';
// Retorna el perfil actualizado
return dataProfile;
}))
// Si no hay URL de avatar, retorna el perfil sin modificar
: of(dataProfile);
}
)
);
}
La función anterior está comentada línea por línea porque es complicada.
El formulario de perfil es como cualquier otro formulario, pero es interesante la manera de crearlo y de actualizar los datos de perfil:
const profileForm = () => {
const divProfile = document.createElement('div');
const profileObservable = getProfile();
profileObservable.subscribe((dataProfile) => {
dataProfile = dataProfile[0];
divProfile.innerHTML = `<form action="action_page.php" id="formProfile" style="border: 1px solid #ccc">
<div class="container">
...
<img class="avatar_profile" style="max-width: 200px" id="avatar_prev" src="${dataProfile.avatar_blob ? dataProfile.avatar_blob : ''}"/>
</div>
<label for="avatar"><b>Avatar</b></label>
<input
type="file"
id="avatar"
name="avatar"
/>
....
</form>`;
divProfile.querySelector('#update').addEventListener('click', async () => {
const formData = new FormData(divProfile.querySelector('#formProfile'));
const {
username, full_name, website, avatar,
} = Object.fromEntries(formData);
(await updateProfile({
username, full_name, website, avatar,
}));
window.location.hash = "#/profile";
});
function encodeImageFileAsURL(element) {
const file = element.files[0];
if (file) {
divProfile.querySelector('#avatar_prev').src = URL.createObjectURL(file);
}
}
divProfile.querySelector('#avatar').addEventListener('change', function () { encodeImageFileAsURL(this); });
});
return divProfile;
}