Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,7 @@
## 2025-06-17 - [Accessible Checkboxes and Consistent Prop Spreading]
**Learning:** Enhancing base form components like `CustomCheckbox` with standardized `required` indicators (visual asterisk and `aria-required`) and grid support (`s`, `m`, `l`, `xl`, `offset`) improves both accessibility and developer productivity. Ensuring that `...props` are consistently applied to the inner `input` regardless of the presence of a wrapper `div` maintains a predictable component API.
**Action:** Always provide visual and semantic cues for mandatory fields and ensure consistent prop-spreading behavior in multi-layered components.

## 2025-06-27 - [Semantic Form Submission, Label Associations, and Radio Button A11y]
**Learning:** Wrapping input fields inside a semantic `<form>` element restores native browser submit support, enabling keyboard navigation and Enter key submissions. When designing list/grid-based form inputs (like question option fields), ensuring each input has a corresponding `<label>` (rather than only a placeholder) prevents the label from disappearing when users start typing. Additionally, using descriptive `aria-label` and `title` attributes on associated radio buttons improves screen reader compatibility. In Playwright, custom styled layouts may intercept pointer events, which can be bypassed using `click(force=True)` to directly trigger the radio button input.
**Action:** Always wrap interactive form sections in a `<form>` element, strictly enforce ID/htmlFor label-input associations, and write robust Playwright selectors using `force=True` when layouts trigger pointer-event overlaps.
136 changes: 136 additions & 0 deletions src/v2/__tests__/V2Contribuir.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2Contribuir from '../pages/V2Contribuir';
import { alertSuccess, alertError } from '../../services/AlertService';

vi.mock('../../services/AlertService', () => ({
alertSuccess: vi.fn(),
alertError: vi.fn(),
}));

describe('V2Contribuir', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders correctly with all headers and inputs', () => {
render(
<MemoryRouter>
<V2Contribuir />
</MemoryRouter>
);

// Headers
expect(screen.getByText('Contribuir Caso Clínico')).toBeTruthy();
expect(screen.getByText('Escenario Clínico')).toBeTruthy();
expect(screen.getByText('La Pregunta')).toBeTruthy();
expect(screen.getByText('Opciones de Respuesta')).toBeTruthy();
expect(screen.getByText('Perla Médica')).toBeTruthy();

// Inputs/Textareas with labels
expect(screen.getByLabelText('Descripción detallada')).toBeTruthy();
expect(screen.getByLabelText('Pregunta diagnóstica/terapéutica')).toBeTruthy();
expect(screen.getByLabelText('Justificación académica')).toBeTruthy();

// Option labels A, B, C, D
expect(screen.getByLabelText('Opción A')).toBeTruthy();
expect(screen.getByLabelText('Opción B')).toBeTruthy();
expect(screen.getByLabelText('Opción C')).toBeTruthy();
expect(screen.getByLabelText('Opción D')).toBeTruthy();

// Radio buttons with aria-labels
expect(screen.getByLabelText('Marcar opción A como correcta')).toBeTruthy();
expect(screen.getByLabelText('Marcar opción B como correcta')).toBeTruthy();
expect(screen.getByLabelText('Marcar opción C como correcta')).toBeTruthy();
expect(screen.getByLabelText('Marcar opción D como correcta')).toBeTruthy();

// Submit button
expect(screen.getByRole('button', { name: /Enviar para Revisión/i })).toBeTruthy();
});

it('allows user input across all fields and interactive correct answer selection', () => {
render(
<MemoryRouter>
<V2Contribuir />
</MemoryRouter>
);

const casoInput = screen.getByLabelText('Descripción detallada');
const preguntaInput = screen.getByLabelText('Pregunta diagnóstica/terapéutica');
const perlaInput = screen.getByLabelText('Justificación académica');

fireEvent.change(casoInput, { target: { value: 'Paciente de 30 años con tos.' } });
fireEvent.change(preguntaInput, { target: { value: '¿Cuál es el agente?' } });
fireEvent.change(perlaInput, { target: { value: 'Es neumococo debido a...' } });

expect(casoInput.value).toBe('Paciente de 30 años con tos.');
expect(preguntaInput.value).toBe('¿Cuál es el agente?');
expect(perlaInput.value).toBe('Es neumococo debido a...');

const opcionA = screen.getByLabelText('Opción A');
fireEvent.change(opcionA, { target: { value: 'S. pneumoniae' } });
expect(opcionA.value).toBe('S. pneumoniae');

// Click radio button for Option C
const radioC = screen.getByLabelText('Marcar opción C como correcta');
expect(radioC.checked).toBe(false);
fireEvent.click(radioC);
expect(radioC.checked).toBe(true);
});

it('shows error alert on empty form submission', () => {
render(
<MemoryRouter>
<V2Contribuir />
</MemoryRouter>
);

const submitBtn = screen.getByRole('button', { name: /Enviar para Revisión/i });
fireEvent.click(submitBtn);

expect(alertError).toHaveBeenCalledWith(
'Campos vacíos',
'Por favor, completa todos los campos antes de enviar.'
);
});

it('successfully submits contribution when all fields are completed', async () => {
render(
<MemoryRouter>
<V2Contribuir />
</MemoryRouter>
);

// Fill all inputs
fireEvent.change(screen.getByLabelText('Descripción detallada'), { target: { value: 'Caso de prueba' } });
fireEvent.change(screen.getByLabelText('Pregunta diagnóstica/terapéutica'), { target: { value: 'Pregunta de prueba' } });
fireEvent.change(screen.getByLabelText('Justificación académica'), { target: { value: 'Perla de prueba' } });

fireEvent.change(screen.getByLabelText('Opción A'), { target: { value: 'Opcion A text' } });
fireEvent.change(screen.getByLabelText('Opción B'), { target: { value: 'Opcion B text' } });
fireEvent.change(screen.getByLabelText('Opción C'), { target: { value: 'Opcion C text' } });
fireEvent.change(screen.getByLabelText('Opción D'), { target: { value: 'Opcion D text' } });

// Submit
const submitBtn = screen.getByRole('button', { name: /Enviar para Revisión/i });
fireEvent.click(submitBtn);

// Should enter loading state
expect(screen.getByText('Enviando...')).toBeTruthy();

// Wait for submission simulation
await waitFor(() => {
expect(alertSuccess).toHaveBeenCalledWith(
'¡Gracias por tu aporte!',
'Tu caso clínico ha sido enviado y será revisado por nuestro equipo médico.'
);
}, { timeout: 2000 });

// Form should be reset
expect(screen.getByLabelText('Descripción detallada').value).toBe('');
expect(screen.getByLabelText('Pregunta diagnóstica/terapéutica').value).toBe('');
expect(screen.getByLabelText('Justificación académica').value).toBe('');
expect(screen.getByLabelText('Opción A').value).toBe('');
});
});
55 changes: 49 additions & 6 deletions src/v2/pages/V2Contribuir.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { alertSuccess, alertError } from '../../services/AlertService';
import CustomPreloader from '../../components/custom/CustomPreloader';
import '../styles/v2-theme.css';

const V2Contribuir = () => {
Expand All @@ -9,6 +11,7 @@ const V2Contribuir = () => {
correcta: 0,
perla: ''
});
const [loading, setLoading] = useState(false);

const handleChange = (field, value) => setForm(prev => ({ ...prev, [field]: value }));
const handleOptionChange = (idx, value) => {
Expand All @@ -17,14 +20,39 @@ const V2Contribuir = () => {
setForm(prev => ({ ...prev, opciones: newOps }));
};

const handleSubmit = async (e) => {
e.preventDefault();
if (!form.caso.trim() || !form.pregunta.trim() || !form.perla.trim() || form.opciones.some(op => !op.trim())) {
alertError('Campos vacíos', 'Por favor, completa todos los campos antes de enviar.');
return;
}
setLoading(true);
try {
// Simular envío a revisión
await new Promise(resolve => setTimeout(resolve, 1500));
alertSuccess('¡Gracias por tu aporte!', 'Tu caso clínico ha sido enviado y será revisado por nuestro equipo médico.');
setForm({
caso: '',
pregunta: '',
opciones: ['', '', '', ''],
correcta: 0,
perla: ''
});
} catch {
alertError('Error', 'No se pudo enviar tu contribución. Intenta de nuevo.');
} finally {
setLoading(false);
}
};

return (
<div className='v2-page-container'>
<header className='v2-mb-32'>
<h1 className='v2-headline-medium'>Contribuir Caso Clínico</h1>
<p className='v2-body-large v2-opacity-70'>Tu aporte mejora la preparación de miles de colegas aspirantes.</p>
</header>

<div className='v2-grid v2-gap-24' style={{ gridTemplateColumns: '1.5fr 1fr' }}>
<form onSubmit={handleSubmit} className='v2-grid v2-gap-24' style={{ gridTemplateColumns: '1.5fr 1fr' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<section className="v2-card">
<h2 className='v2-title-large v2-mb-20'>
Expand Down Expand Up @@ -70,13 +98,19 @@ const V2Contribuir = () => {
{form.opciones.map((op, idx) => (
<div key={idx} className='v2-flex-align-center v2-gap-12'>
<input
id={`correcta-${idx}`}
type="radio"
name="correcta"
checked={form.correcta === idx}
onChange={() => handleChange('correcta', idx)}
style={{ width: '20px', height: '20px', accentColor: 'var(--md-sys-color-primary)' }}
style={{ width: '20px', height: '20px', accentColor: 'var(--md-sys-color-primary)', cursor: 'pointer' }}
aria-label={`Marcar opción ${String.fromCharCode(65 + idx)} como correcta`}
title={`Marcar opción ${String.fromCharCode(65 + idx)} como correcta`}
/>
<div className="v2-input-outlined" style={{ flex: 1, marginTop: '0' }}>
<label htmlFor={`opcion-${idx}`}>{`Opción ${String.fromCharCode(65 + idx)}`}</label>
<input
id={`opcion-${idx}`}
style={{ padding: '12px' }}
placeholder={`Opción ${String.fromCharCode(65 + idx)}`}
value={op}
Expand Down Expand Up @@ -105,12 +139,21 @@ const V2Contribuir = () => {
</div>
</section>

<button className='v2-btn-filled v2-btn-h-56 v2-btn-full v2-btn-justify-center'>
Enviar para Revisión
<i className="material-icons">send</i>
<button type="submit" className='v2-btn-filled v2-btn-h-56 v2-btn-full v2-btn-justify-center' disabled={loading}>
{loading ? (
<span className='v2-flex-align-center v2-gap-12'>
<CustomPreloader size='small' />
Enviando...
</span>
) : (
<>
Enviar para Revisión
<i className="material-icons" aria-hidden="true">send</i>
</>
)}
</button>
</div>
</div>
</form>
</div>
);
};
Expand Down
Loading