Allow users to update song ratings

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 4ce062fbb5
commit fe42b4b6ce

@ -16,13 +16,14 @@
import { page } from '$app/state';
interface Props {
cancionSlug: string;
/** Media de 1 a 5, o `null` si todavía no ha votado nadie. */
media: number | null;
/** Lo que puso quien mira, si entró y votó. */
mia?: number;
}
let { media, mia }: Props = $props();
let { cancionSlug, media, mia }: Props = $props();
const ESTRELLAS = [1, 2, 3, 4, 5];
@ -31,7 +32,18 @@
/** Se envía mientras vuela la respuesta, para que la elección no parpadee. */
let enviando = $state<number | undefined>(undefined);
const elegida = $derived(enviando ?? mia);
/**
* Último voto que la acción confirmó en esta visita.
*
* No se depende solo de que el `load` vuelva a traer `mia`: entre la
* respuesta de la acción y esa recarga el radio regresaba al valor anterior,
* y daba la impresión —además de dejar el control en un estado incoherente—
* de que un voto ya emitido no se podía modificar.
*/
let confirmada = $state<{ cancionSlug: string; puntuacion: number } | undefined>(undefined);
const elegida = $derived(
enviando ?? (confirmada?.cancionSlug === cancionSlug ? confirmada.puntuacion : undefined) ?? mia
);
</script>
<section class="valoracion" aria-label="Valoración de la canción">
@ -40,14 +52,19 @@
method="POST"
action="?/valorar"
use:enhance={({ formData }) => {
enviando = Number(formData.get('puntuacion'));
return async ({ update }) => {
await update({ reset: false });
enviando = undefined;
const puntuacion = Number(formData.get('puntuacion'));
enviando = puntuacion;
return async ({ result, update }) => {
try {
await update({ reset: false });
if (result.type === 'success') confirmada = { cancionSlug, puntuacion };
} finally {
enviando = undefined;
}
};
}}
>
<fieldset>
<fieldset disabled={enviando !== undefined} aria-busy={enviando !== undefined}>
<legend class="visually-hidden">Tu valoración, de 1 a 5</legend>
<div class="estrellas">
{#each ESTRELLAS as estrella (estrella)}
@ -125,6 +142,8 @@
}
.estrellas label {
position: relative;
display: inline-flex;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
@ -164,9 +183,12 @@
/* El radio real sigue ahí: es quien recibe el foco y el teclado. */
.estrellas input {
position: absolute;
width: 1px;
height: 1px;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.estrellas label:has(input:focus-visible) {

@ -0,0 +1,68 @@
import { page } from 'vitest/browser';
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Valoracion from './Valoracion.svelte';
vi.mock('$app/state', () => ({
page: {
url: new URL('http://localhost/canciones/prometiste'),
data: { usuario: { id: 'usuario-prueba' } },
form: null
}
}));
type AlEnviar = (entrada: {
formData: FormData;
}) => (salida: {
result: { type: 'success'; status: number; data: Record<string, unknown> };
update: (opciones: { reset: boolean }) => Promise<void>;
}) => Promise<void>;
/*
* El componente solo necesita aquí el contrato de mejora progresiva: capturar
* el formulario, ejecutar su callback y confirmar una respuesta correcta. La
* persistencia se comprueba en el módulo del servidor; esta prueba protege el
* estado que ve y puede volver a pulsar la persona.
*/
vi.mock('$app/forms', () => ({
enhance(formulario: HTMLFormElement, alEnviar: AlEnviar) {
const enviar = async (evento: SubmitEvent) => {
evento.preventDefault();
const formData = new FormData(formulario);
const completar = alEnviar({ formData });
await completar({
result: {
type: 'success',
status: 200,
data: { valoracion: { puntuacion: Number(formData.get('puntuacion')) } }
},
update: async () => {}
});
};
formulario.addEventListener('submit', enviar);
return { destroy: () => formulario.removeEventListener('submit', enviar) };
}
}));
describe('Valoracion', () => {
it('permite reemplazar una valoración ya emitida', async () => {
const vista = render(Valoracion, { cancionSlug: 'prometiste', media: 2, mia: 2 });
const dos = page.getByRole('radio', { name: '2 de 5' });
const cuatro = page.getByRole('radio', { name: '4 de 5' });
await expect.element(dos).toBeChecked();
await cuatro.click();
await expect.element(cuatro).toBeChecked();
await expect.element(dos).not.toBeChecked();
await expect.element(cuatro).toBeEnabled();
// La confirmación local pertenece a ese tema, no al componente reutilizado
// al navegar a otra ficha de canción.
await vista.rerender({ cancionSlug: 'otra-cancion', media: 1, mia: 1 });
await expect.element(page.getByRole('radio', { name: '1 de 5' })).toBeChecked();
await expect.element(cuatro).not.toBeChecked();
});
});

@ -159,7 +159,11 @@
{/if}
<div class="valoracion-superior">
<Valoracion media={data.valoracion.media} mia={data.miValoracion} />
<Valoracion
cancionSlug={cancion.slug}
media={data.valoracion.media}
mia={data.miValoracion}
/>
</div>
<!--

Loading…
Cancel
Save

Powered by TurnKey Linux.