Спрощуємо використання React Bootstrap Forms
Привіт! Мене звати Артем, я Full Stack Developer. Нещодавно почав використовувати React для Front-end на проєктах і хочу обмінятись досвідом з колегами. Попередня моя стаття з циклу про веброзробку — Автоматизуємо використання адаптивних зображень для вебсайтів за допомогою Node.js.
В React робота з формами дещо відрізняється від класичного підходу в HTML. Через це додатково доводиться прописувати та опрацьовувати прості дії користувача. Зі збільшенням кількості полів у формі та форм у проєкті виникає багато зайвих повторюваних операцій.
Інструменти на зразок React та Bootstrap допомагають розробникам позбавитись необхідності писати повторюваний код. Хоча деколи навіть вони не можуть забезпечити достатній рівень автоматизації своїм базовим функціоналом. Нижче йтиме мова про те, як можна цей функціонал розширити для спрощення створення великої кількості форм на сайті з використанням React Bootstrap Forms.
Універсальний елемент вводу
Для вводу даних у формах React Bootstrap, за аналогією з HTML, використовуються декілька різних компонентів: Control
, Select
, Check
та інші. При цьому в них застосовуються мінімальна уніфікація та розширення базового функціоналу. Наприклад, замість окремого елемента textarea
— компонент Control
зі значенням textarea
у властивості as
.
Ще цікавіші зміни присутні в компоненті Check
. З незрозумілих причин на його основі, окремо від основного Control
, реалізовані типові елементи checkbox
та radio
. Всі вони застосовуються за допомогою використання відповідного значення для властивості type
. З іншого боку, додано корисний компонент Switch
.
Мені суб’єктивно такий підхід здається дещо заплутаним, тому я пропоную почати з уніфікації всіх елементів вводу та створення єдиного універсального компонента Control
, який буде набирати той чи інший вигляд залежно від відповідного значення властивості type
: text
, date
, select
, radio
, textarea
тощо.
import React, { useEffect, useContext } from 'react' import { Form } from 'react-bootstrap' import Context from '../../Context.js' export default props => { const data = useContext(Context) const handleChange = event => { if (props?.onChange) { props?.onChange(event) } else { data.set( event.target.name, event.target.type === 'checkbox' ? event.target.checked : event.target.value ) } } const handleBlur = event => { if (props?.onBlur) props?.onBlur(event) if ((props.type === 'textarea') && event.target.value.length) { if (props?.pattern) { if (event.target.value.match(props.pattern)) { event.target.setCustomValidity('') } else { event.target.setCustomValidity( props?.title ?? 'Невірний формат' ) } } } else { event.target.setCustomValidity('') } event.target.reportValidity() } useEffect(() => { if ('value' in props) { data.set(props.name, props.value, false) } }, []) switch(props?.type) { case 'radio': case 'switch': case 'checkbox': return <Form.Check {...props} checked={data.get(props.name) ?? false} onChange={handleChange} /> case 'select': return <Form.Select {...props} value={data.get(props.name)} onChange={handleChange}> {props?.children} </Form.Select> case 'textarea': return <Form.Control as="textarea" {...props} value={data.get(props.name)} onChange={handleChange} onBlur={handleBlur} /> default: { return <Form.Control autoComplete="off" {...props} value={data.get(props.name) ?? ''} onChange={handleChange} onBlur={handleBlur} /> } } }
Залежно від значення властивості props.type
повертається стандартний компонент React Bootstrap. Додатково опрацьовуються типові події onChange
та onBlur
включно з валідацією введених даних. За допомогою хуку useEffect
встановлюються типові значення елементів вводу, якщо вони задані в батьківській компоненті.
Функції data.set()
та data.get()
, подібні до стандартних функцій об’єкта FormData
, використовуються для отримання та збереження даних форми. Але описані вони будуть наприкінці статті, при створенні компонента Form
.
Універсальне поле вводу
Ми спростили елемент вводу, але він зрідка використовується в чистому вигляді. Зазвичай в прикладах Bootstrap він описується разом з підписом, текстовим описом, іншими полями вводу чи кнопками в групі. Для спрощення застосування цих елементів разом можна додатково створити компоненту Field
.
import React from 'react' import { Form } from 'react-bootstrap' import Control from './Field/Control.js' const Field = props => { const { className, label, ...propsNew } = props return ( <Form.Group className={'field ' + props.className ?? ''}> {props?.label && <Form.Label>{props.label}</Form.Label>} {props?.type ? <Control {...propsNew}>{props?.children}</Control> : props.children} </Form.Group> ) } export { Field as default, Control }
Універсальне поле Field
дає можливість зручно додавати в форму елементи вводу разом з підписом зверху.
Типові поля вводу
При створенні для вебсайту багатьох форм деколи їх поля можуть повторюватись. Наприклад, поля Title
, Description
чи Slug
доволі поширені. Щоб їх не дублювати в різних формах, можна створити типові поля (field presets). Для перших двох спочатку створимо типове поле Text
.
import React from 'react' import Field from './Field.js' export default props => { const minLength = props.minLength ?? 1; const maxLength = props.maxLength ?? 65_536; const title = `Від ${minLength} до ${maxLength} символів`; return <Field type={props?.rows ? 'textarea' : 'input'} name="text" pattern=".*" label="Текст" rows={props.rows} placeholder="Текст ..." title={title} minLength={length.min} maxLength={length.max} {...props} /> }
А вже на його основі створюємо найпоширеніше типове поле Title
.
import React from 'react' import Text from './Text.js' export default props => { return <Text name="title" label="Назва" placeholder="Назва ..." maxLength="128" {...props} /> }
Також на його основі можна створити типове поле Description
, яке через наявність атрибута rows
при виводі набере вигляду HTML-елемента textarea
.
import React from 'react' import Text from './Text.js' export default props => { return <Text name="description" rows="3" label="Опис" placeholder="Опис ..." maxLength="256" {...props} /> }
В типовому полі Slug
ми додаємо подію для автоматичного створення його значення з вхідної властивості props.source
, за умови, що посилання ще не було створене раніше. Через цю властивість зазвичай передають значення властивості Title
даних форми, щоб посилання створювалось на основі назви.
import React, { useContext } from 'react' import Context from '../Context.js' import Field from './Field.js' export default props => { const name = props.name ?? 'slug' const data = useContext(Context) const handleChange = event => { if (!props?.source || event.target.value) return const value = props.source.replaceAll(/[^0-9a-zа-яієїґ\-]/i) data.set(name, value) } return <Field type="text" name={name} onFocus={handleChange} onBlur={handleChange} label="Посилання" pattern="[0-9a-zа-яієїґ\-]{1,128}" placeholder="відносне-посилання" title="Посилання (від 1 до 128 прописних латинських букв, цифр та дефісів)" {...props} /> }
Також варто розглянути типове поле Status
. Воно, на відміну від попередніх прикладів, створюється не з типового поля Field
, а безпосередньо зі створеного нами раніше компонента Control
та компонентів Bootstrap Form.Group
та Form.Label
. Такий універсальний підхід дає можливість інтегрувати типові поля, які відрізняються від базового поля Field
.
import React from 'react' import { Form } from 'react-bootstrap' import Control from './Field/Control.js' export default props => { const { className, label, ...propsNew } = { name: 'status', ...props } return ( <Form.Group className={props.className}> <Form.Label>{props.label ?? 'Статус'}</Form.Label> {(props?.as && props.as === 'select') ? (<Control type="select" {...propsNew} title="Фільтр за статусом" value={true}> <option value="">Всі</option> <option value={true}>Видимі</option> <option value={false}>Приховані</option> </Control>) : <Control type="switch" className="mt-2" value={true} title="Дозвіл на використання" {...propsNew} /> } </Form.Group> ) }
Конкретно в цьому випадку типове поле можна використовувати як звичайний перемикач або як вибір зі списку. Річ у тім, що форму можна використовувати не тільки для редагування даних, а й для фільтрування записів у списку за певними критеріями. Для першого випадку поле виводиться як перемикач, але при передачі значення select
в атрибуті as
— як список.
import Field, { Control } from './Fields/Field.js' const modules = [ 'Date', 'DateTime', 'Title', 'Description', 'Body', 'Text', 'Password', 'Tag', 'Latitude', 'Longitude', 'Slug', 'Status', 'Sort', 'Bool', 'Limit' ] const fields = { Field } for (const module of modules) { fields[module] = (await import( /* webpackMode: "eager" */`./Fields/${module}.js`) ).default } export { fields as default, Control }
Зрештою об’єднуємо всі наші типові поля в один модуль для зручності, єдиним завданням якого є їх автоматичний імпорт та експорт як властивості єдиного об’єкта Fields
. Додатково ще експортуємо базовий компонент Control
для можливості реалізації нестандартних полів.
Рядок та комірка форми
Загалом майже все готово для створення самої форми. Але додатково напишу про метод формування її структури. Найпростіше реалізувати за допомогою компонентів Row
та Col
, які належать до розмітки Bootstrap Grid.
import React from 'react' import { Row } from 'react-bootstrap' export default props => { return <Row className="mt-4" {...props}>{props?.children}</Row> }
Для зручності я зробив для них обгортки Row
та Cell
, які можна адаптувати для вашої структури форми, але це не обов’язково.
import React from 'react' import { Col } from 'react-bootstrap' export default props => { return <Col {...props}>{props?.children}</Col> }
Шаблон типової форми
Нарешті дійшли до створення шаблону типової форми. Для того, щоб назва не конфліктувала з компонентою Form
з React Bootstrap, я називаю її FormWrapper
. Але використовувати форму можна під будь-якою назвою, створеною під час імпорту. Також за її допомогою переносимо посилання на інші компоненти, описані раніше: Fields
, Control
, Row
, Cell
.
import React from 'react' import { Form, Button } from 'react-bootstrap' import Context from './Form/Context.js' import Fields, { Control } from './Form/Fields.js' import Row from './Form/Row.js' import Cell from './Form/Cell.js' import './Form/form.css' const recurse = (data, name, value, override = true) => { if (name.length > 1) { if (typeof data[name[0]] === 'undefined') { if (typeof value === 'undefined') return data[name[0]] data[name[0]] = {} } const result = recurse(data[name[0]], name.slice(1), value, override) if ((value === null) && !Object.keys(data[name[0]]).length) { delete data[name[0]] } return result; } else { if (typeof value === 'undefined') return data[name[0]] if (value === null) return delete data[name[0]] if ((name[0] in data) && !override) return data[name[0]] = value } } const FormWrapper = props => { const data = { set: (name, value, override) => { props.onChange(dataOld => { const dataNew = { ...dataOld } const valueNew = (typeof value === 'undefined') ? null : value recurse(dataNew, name.split('.'), valueNew, override) return dataNew }) }, get: name => { return recurse(props.data, name.split('.')) } } const handleDelete = () => { if (confirm('Ви впевненні?')) { props.onDelete() } } const handleSubmit = event => { event.preventDefault() event.stopPropagation() props.onSubmit( Object.fromEntries( new FormData(event.target) ) ) } return <Context.Provider value={data}> <Form onSubmit={handleSubmit} className="border p-3 mt-5 mb-3 mx-auto"> <h4>{props.title}</h4> <hr /> <div className="fields mt-5">{props.children}</div> <p className="buttons mt-5 text-center"> {props?.data?._id && props?.onDelete && ( <Button onClick={handleDelete} variant="danger" title="Видалити документ" className="me-2"> Видалити </Button> )} <Button type="submit" variant="success" title="Зберегти документ"> Зберегти </Button> </p> </Form> </Context.Provider> } export { FormWrapper as default, Fields, Control, Row, Cell }
Функція handleSubmit()
виконується при спробі відправити форму. Вона отримує дані з форми та передає їх батьківській події props.onSubmit()
. При чому цей спосіб передачі даних не є основним, адже всі дані форми оновлюються автоматично під час змін в елементах вводу. Функція handleDelete()
при натисканні відповідної кнопки форми виконує батьківську функцію props.onDelete()
, яка містить команди з видалення усього запису даних форми, попередньо запитавши у користувача.
Функції data.set()
та data.get()
, про які згадував на початку статті, здійснюють збереження та читання даних форми за її назвою. Для кожної властивості даних форми існує можливість встановлення типового значення на різних рівнях виконання дочірніх компонентів форми. Але через їх певний порядок виконання можливо помилково перезаписати вже встановлене значення. Для уникнення цієї ситуації значення третього параметру функції data.set(name, value, override)
необхідно встановити false
.
Функція recurse()
необхідна для підтримки роботи з деревоподібною структурою даних об’єкта, що редагуються у формі. Адже JavaScript та MongoDB вміють працювати з такими об’єктами. А ось для автоматичної підтримки ще й у формі, необхідно назву поля писати в точковій нотації. Тоді за допомогою рекурсії вона зможе записувати та отримувати дані в об’єкті довільної глибини вкладеності.
... <Row> <Cell sm={6}> <Fields.Latitude label="Широта центру" name="location.center.latitude" /> </Cell> <Cell sm={6}> <Fields.Longitude label="Довгота центру" name="location.center.longitude" /> </Cell> </Row> ...
Зрештою функції для збереження та читання даних у вигляді об’єкта зберігаємо в Context
та передаємо їх вниз по ієрархії за допомогою компонента Provider
. Тепер доступ до даних можливо отримати в будь-якому з дочірніх елементів. Зміст файлу Context.js
передбачувано виглядає доволі мінімалістично.
import { createContext } from 'react' export default createContext({})
Приклад використання форми
import React, { useState } from 'react' import Form, { Fields, Row, Cell } from './Form.js' const userSample = { title: 'Тарас Шевченко', slug: 'taras-shevchenko', phone: '+0 (12) 345 67 89', email: '[email protected]', description: 'Пише гарні вірші ...', date: '1814-03-09', role: 1, status: true } const rolesSample = [ { id: 1, title: 'Адміністратор' }, { id: 2, title: 'Головний редактор' }, { id: 3, title: 'Редактор' }, { id: 4, title: 'Автор' }, { id: 5, title: 'Читач' } ] export default () => { const [user, setUser] = useState(userSample) const [roles, setRoles] = useState(rolesSample) const handleSubmit = () => { console.log('handleSubmit', user) } const handleDelete = () => { console.log('handleDelete') } return <Form data={user} onChange={setUser} onSubmit={handleSubmit} onDelete={handleDelete} title="Редагування користувача"> <Row> <Cell sm="6"> <Fields.Title placeholder="Леся Українка" required /> </Cell> <Cell sm="6"> <Fields.Slug source={user.title} placeholder="lesia-ukrainka" required /> </Cell> </Row> <Row> <Cell sm="6"> <Fields.Field type="tel" name="phone" label="Телефон" placeholder="+38 098 765-43-21" /> </Cell> <Cell sm="6"> <Fields.Field type="email" name="email" label="Пошта" placeholder="[email protected]" required /> </Cell> </Row> <Row> <Cell sm="6"> <Fields.Password name="password1" /> </Cell> <Cell sm="6"> <Fields.Password name="password2" label="Пароль (повторно)" /> </Cell> </Row> <Row> <Fields.Description label="Нотатки" placeholder="Короткий опис ..." /> </Row> <Row> <Cell sm="4"> <Fields.Date label="Дата народження" /> </Cell> <Cell sm="5"> <Fields.Field type="select" name="role" label="Роль"> {roles?.map(role => ( <option value={role.id} key={role.id}> {role.title} </option> ))} </Fields.Field> </Cell> <Cell sm="3"> <Fields.Status label="Активний" /> </Cell> </Row> </Form> }
Для демонстрації можливостей я вирішив обрати форму редагування даних користувача як одну з найпоширеніших. Спочатку імпортуємо створені нами раніше компоненти Form
, Fields
, Row
, Cell
. Потім створюємо демонстраційні дані userSample
та rolesSample
, які в реальному проєкті ви зазвичай отримуєте з бази данних. Функції handleSubmit()
та handleDelete()
, щоб не писати код взаємодії з БД, використані лише для відлагодження.
А от форма наведена максимально реалістично. В компоненту форми передаємо дані користувача через властивість data, відповідні функції подій через властивості onChange
, onSubmit
, onDelete
та, зрештою, заголовок форми через властивість title
.
Візуальну структуру форми вибудовуємо за допомогою компонентів Row
та Cell
. А вже в її комірках вписуємо або типові поля (Title
, Slug
, Password
, Description
, Date
, Status
), або налаштовуємо універсальне поле Field
(phone
, email
, role
) з відповідними типами tel
, email
та select
.
Якщо ж вам необхідно створити поле, вигляд якого відрізняється від стандартного, можна прямо в будь-якій комірці даної форми скомпонувати персональний вигляд поля зі стандартних компонентів. За приклад можна використати компоненту універсального поля вводу Field
або компоненту типового поля Status
, наведені раніше в статті.
Тепер подивимось, що в нас вийшло в результаті.
Схема компонентів форми
Хоча описаний в статті підхід до створення форми надзвичайно простий, непідготовленому програмісту він може здатись дещо заплутаним. Через це я вирішив створити схему компонентів форми зі зв’язками між ними. Вона допоможе легко зрозуміти її частини та способи їх використання.
Використання багатьох обгорток неодмінно збільшує навантаження на процесор. Але практика показує, що не сильно. Та загалом швидкодія роботи сайту залишається на прийнятному рівні. Хоча в різних проєктах додаткове навантаження може бути різним, тому звертати увагу на це однаково варто.
Звісно, створювати всі ці проміжні компоненти через одну або навіть декілька форм особливого сенсу немає. Але якщо в проєкті їх багато, доцільність різко виростає. Зрештою, що більше у вас форм в проєкті, то доцільніше використання уніфікацій та автоматизацій при створенні форм, описаних в даній статті.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів