Спрощуємо використання React Bootstrap Forms

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт! Мене звати Артем, я 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
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Нахіба комусь треба форми з react-bootstrap, коли є reach-hook-form + literally будь-яка інша ліба компонентів/стилізації?

Підписатись на коментарі