
Gabriel Jiménez | Hace 4 días
Los formularios son un elemento muy utilizados en aplicaciones frontend, ya sean web, móvil y de escritorio. Diseñarlos bien desde un inicio, nos permite que sean más fáciles de mantener y escalar. Al diseñarlos, lo primero es elegir una librería de estilos y luego determinar un flujo de envío hacia el backend.
Para este ejercicio, utilizaremos Material UI como librería de estilos e implementaremos nuestro propio flujo de envío de formularios.
Cualquiera de las opciones es totalmente valida, pero elegir una sobre otra, dependerá del contexto del proyecto, por ejemplo:
Para este ejercicio, vamos a usar la segunda opción: Crear nuestro propio flujo de envío de formularios con Material UI.
Para nuestro diseño, te recomiendo que le eches un vistazo al artículo: Cómo hacer un formulario con React, desde 0, para saber más sobre el diseño.
De forma muy general, en el artículo propongo crear un componente Form, para encapsular como crear formularios de una forma más organizada.
. . . 35: return ( 36: <Form 37: onSubmit={handleSubmit} 38: onCancel={() => console.log("Cancelado")} 39: > 40: <TextField name="nombre" id="nombre">Nombre del cliente</TextField> 41: <TextField name="mascota" id="mascota">Nombre de la mascota</TextField> 42: <SelectField 43: name="raza" 44: id="raza" 45: options={[ 46: { value: "chihuahua", label: "Chihuahua" }, 47: { value: "pastor-aleman", label: "Pastor Alemán" }, 48: ]} 49: > 50: Raza del perro 51: </SelectField> 52: <TextAreaField name="descripcion" id="descripcion">Sobre su mascota</TextAreaField> 53: <TextField name="cita" id="cita" type="date">Fecha de cita</TextField> 54: 55: <ActionField type="submit">Guardar</ActionField> 56: <ActionField type="cancel">Cancelar</ActionField> . . . 68: </Form> . . .
Sin embargo, al usar el formulario habías varias limitaciones.
Analicemos el siguiente código:
. . . 6: function App() { 7: const [errors, setErrors] = useState([]) 8: 9: function validate(values) { 10: const newErrors = [] 11: const today = new Date().toISOString().split("T")[0] // yyyy-mm-dd 12: 13: if (!values.nombre) newErrors.push("El nombre del cliente es obligatorio") 14: if (!values.mascota) newErrors.push("El nombre de la mascota es obligatorio") 15: if (!values.raza) newErrors.push("La raza es obligatoria") 16: if (!values.cita) { 17: newErrors.push("La fecha de cita es obligatoria") 18: } else if (values.cita < today) { 19: newErrors.push("La fecha debe ser hoy o posterior") 20: } 21: 22: return newErrors 23: } 24: 25: function handleSubmit(values) { 26: const validationErrors = validate(values) 27: if (validationErrors.length > 0) { 28: setErrors(validationErrors) 29: return 30: } 31: setErrors([]) 32: console.log("Formulario válido ✅", values) 33: } . . . 58: {errors.length > 0 && ( 59: <div className="form-errors"> 60: <h4>Errores:</h4> 61: <ul> 62: {errors.map((err, i) => ( 63: <li key={i}>{err}</li> 64: ))} 65: </ul> 66: </div> 67: )} . . .
Y la lista puede ir creciendo…
Vamos a atacar esos problemas, y a la par integremos Material UI.
Validaciones y errores dentro del componente Form
A grandes rasgos lo que queremos lograr es que, el formulario gestione los errores de los campos internamente.
Componente Form con estado errors
1: import React, { useState, Children, cloneElement } from 'react'; 2: 3: export const Form = ({ children, onSubmit, onCancel }) => { . . . 22: const [errors, setErrors] = useState( 23: Object.keys(initialValues).reduce((acc, key) => { 24: acc[key] = []; 25: return acc; 26: }, {}) 27: ); . . . 34: function runValidations(rules, value) { 35: if (!rules) return []; 36: 37: const msgs = []; 38: 39: rules.forEach((rule) => { 40: if (rule === 'required') { 41: if (!value || value.toString().trim() === '') { 42: msgs.push('Este campo es obligatorio'); 43: } 44: } else if (rule.startsWith('max:')) { 45: const max = parseInt(rule.split(':')[1], 10); 46: if (value && value.length > max) { 47: msgs.push(`Debe tener máximo ${max} caracteres`); 48: } 49: } else if (rule.startsWith('min:')) { 50: const min = parseInt(rule.split(':')[1], 10); 51: if (!value || value.length < min) { 52: msgs.push(`Debe tener al menos ${min} caracteres`); 53: } 54: } 55: }); 56: 57: return msgs; 58: } 59: 60: function handleSubmit(e) { 61: e.preventDefault(); 62: 63: const nextErrors = {}; 64: fields.forEach((child) => { 65: const name = child.props.name; 66: const value = values[name]; 67: const fieldErrors = runValidations(child.props.validations, value); 68: nextErrors[name] = fieldErrors; 69: }); 70: 71: const hasAnyError = Object.values(nextErrors).some((arr) => arr.length > 0); 72: 73: setErrors(nextErrors); 74: 75: if (hasAnyError) { 76: return; 77: } 78: 79: onSubmit?.(values); 80: } 81: 82: return ( 83: <form onSubmit={handleSubmit} noValidate> 84: {fields.map((child) => 85: cloneElement(child, { 86: key: child.props.name, 87: value: values[child.props.name], 88: onChange: handleChange, 89: errorMessages: errors[child.props.name] || [], 90: }) 91: )} 92: 93: {actions.map((child, idx) => 94: cloneElement(child, { 95: key: `action-${idx}`, 96: onCancel, 97: }) 98: )} 99: </form> 100: ); 101: }; . . .
Analicemos
Línea 22-23
Creamos un estado errors a partir de los campos initialValues. Donde el key es el nombre del campo y el valor un arreglo de errores.
Línea 34-55
Función que devuelve los mensajes de errores, si alguna validación falla.
Línea 63-69
Ejecuta las validaciones de los campos y si tienen errores los almacena en un objeto.
Línea 71
Verifica si existe algún error.
Línea 74
Actualiza el estado errors.
Línea 75-77
Si existe un error, no sé ejecuta invoca el onSubmit.
Ya tenemos los errores de cada campo, ahora toca pasarlos a los componentes TextField, TextAreaField, SelectField.
Prop errors en cada campo
. . . 82: return ( 83: <form onSubmit={handleSubmit} noValidate> 84: {fields.map((child) => 85: cloneElement(child, { 86: key: child.props.name, 87: value: values[child.props.name], 88: onChange: handleChange, 89: errors: errors[child.props.name] || [], 90: }) 91: )} . . . 99: </form> 100: ); . . .
Para mostrar los errores, solo tenemos que iterar en el arreglo de errores.
. . . 103: export const TextField = ({ name, id, children, type = 'text', value, onChange, errors = [] }) => { 104: const label = typeof children === 'string' ? children : ''; 105: 106: return ( 107: <div className="textfield"> 108: <label htmlFor={id || name}>{label}</label> 109: <input type={type} name={name} id={id || name} value={value} onChange={onChange} /> 110: {errors.length > 0 && ( 111: <div className="field-errors"> 112: {errors.map((msg, i) => ( 113: <div key={i} className="field-error"> 114: {msg} 115: </div> 116: ))} 117: </div> 118: )} 119: </div> 120: ); 121: }; 122: 123: export const TextAreaField = ({ name, id, children, value, onChange, errors = [] }) => { 124: const label = typeof children === 'string' ? children : ''; 125: 126: return ( 127: <div className="textareafield"> 128: <label htmlFor={id || name}>{label}</label> 129: <textarea name={name} id={id || name} value={value} onChange={onChange} /> 130: {errors.length > 0 && ( 131: <div className="field-errors"> 132: {errors.map((msg, i) => ( 133: <div key={i} className="field-error"> 134: {msg} 135: </div> 136: ))} 137: </div> 138: )} 139: </div> 140: ); 141: }; 142: 143: export const SelectField = ({ name, id, options = [], children, value, onChange, errors = [] }) => { 144: const label = typeof children === 'string' ? children : ''; 145: 146: return ( 147: <div className="selectfield"> 148: <label htmlFor={id || name}>{label}</label> 149: <select name={name} id={id || name} value={value} onChange={onChange}> 150: <option value="">Selecciona una opción</option> 151: {options.map((option, index) => ( 152: <option key={index} value={option.value}> 153: {option.label ?? option.value} 154: </option> 155: ))} 156: </select> 157: {errors.length > 0 && ( 158: <div className="field-errors"> 159: {errors.map((msg, i) => ( 160: <div key={i} className="field-error"> 161: {msg} 162: </div> 163: ))} 164: </div> 165: )} 166: </div> 167: ); 168: }; . . .
Y finalmente, para usar el formulario solo hay que agregar el prop validations a los campos.
1: import React from 'react' 2: import {ActionField, Form, SelectField, TextAreaField, TextField} from "./components/Form/Form"; 3: 4: function App() { 5: return ( 6: <Form onSubmit={(vals) => console.log('OK', vals)}> 7: <TextField 8: name="nombre" 9: validations={["required", "min:3"]} 10: > 11: Nombre 12: </TextField> 13: 14: <SelectField 15: name="raza" 16: options={[ 17: { value: 'chihuahua', label: 'Chihuahua' }, 18: { value: 'pastor-aleman', label: 'Pastor Alemán' }, 19: ]} 20: validations={["required"]} 21: > 22: Raza 23: </SelectField> 24: 25: <TextAreaField 26: name="observaciones" 27: validations={["max:50"]} 28: > 29: Observaciones 30: </TextAreaField> 31: 32: <ActionField type="submit">Guardar</ActionField> 33: <ActionField type="cancel">Cancelar</ActionField> 34: </Form> 35: 36: ) 37: } 38: 39: export default App;
Perfecto, se bien y es funcional.
Ahora solo falta adaptar los componentes de Material UI. Para lograrlo, solo debemos intervenir en los componentes TextField, TextAreaField y SelectField.
. . . 106: export const TextField = ({ 107: name, 108: id, 109: children, 110: type = 'text', 111: value, 112: onChange, 113: errors = [], 114: ...muiProps 115: }) => { 116: const label = typeof children === 'string' ? children : ''; 117: const hasError = errors.length > 0; 118: 119: return ( 120: <MuiTextField 121: {...muiProps} 122: margin="normal" 123: type={type} 124: name={name} 125: id={id || name} 126: label={label} 127: value={value ?? ''} 128: onChange={onChange} 129: error={hasError} 130: helperText={hasError ? errors[0] : (muiProps.helperText ?? ' ')} 131: /> 132: ); 133: }; 134: 135: export const TextAreaField = ({ 136: name, 137: id, 138: children, 139: value, 140: onChange, 141: errors = [], 142: minRows = 3, 143: ...muiProps 144: }) => { 145: const label = typeof children === 'string' ? children : ''; 146: const hasError = errors.length > 0; 147: 148: return ( 149: <MuiTextField 150: {...muiProps} 151: margin="normal" 152: multiline 153: minRows={minRows} 154: name={name} 155: id={id || name} 156: label={label} 157: value={value ?? ''} 158: onChange={onChange} 159: error={hasError} 160: helperText={hasError ? errors[0] : (muiProps.helperText ?? ' ')} 161: /> 162: ); 163: }; 164: 165: export const SelectField = ({ 166: name, 167: id, 168: options = [], 169: children, 170: value, 171: onChange, 172: errors = [], 173: ...muiProps 174: }) => { 175: const label = typeof children === 'string' ? children : ''; 176: const hasError = errors.length > 0; 177: const labelId = `${id || name}-label`; 178: 179: return ( 180: <FormControl margin="normal" error={hasError} {...muiProps.FormControlProps}> 181: <InputLabel id={labelId} {...muiProps.InputLabelProps}>{label}</InputLabel> 182: <MuiSelect 183: {...muiProps} 184: labelId={labelId} 185: id={id || name} 186: name={name} 187: value={value ?? ''} 188: label={label} 189: onChange={onChange} 190: > 191: <MenuItem value=""> 192: <em>Selecciona una opción</em> 193: </MenuItem> 194: {options.map((option, index) => ( 195: <MenuItem key={index} value={option.value} {...muiProps.MenuItemProps}> 196: {option.label ?? option.value} 197: </MenuItem> 198: ))} 199: </MuiSelect> 200: <FormHelperText> 201: {hasError ? errors[0] : (muiProps.helperText ?? ' ')} 202: </FormHelperText> 203: </FormControl> 204: ); 205: }; . . .
Varias cosas que resaltar:
Conclusión
Integrar cualquier librería de estilos como Material UI en la creación de formularios es sencillo, siempre y cuando hayamos diseñado correctamente el flujo de envío de formularios.
Si quieres aprender más sobre como crear formularios más complejos, te invito a leer mi libro: Testing en React: Guía práctica con Jest y React Testing Library.