javascript

Гибкий лэйаут для динамических форм с react-jsonschema-form

  • воскресенье, 23 февраля 2025 г. в 00:00:06
https://habr.com/ru/articles/884862/

Библиотека react‑jsonschema‑form (RJSF) предназначена для автоматической генерации форм на основе JSON‑схемы. Вы задаёте схему, а RJSF берёт на себя остальное: отображение полей ввода, валидацию и обработку данных. Это удобный и простой в использовании инструмент, тем не менее, у библиотеки есть определённые ограничения. Одно из них — отсутствие поддержки многоколоночных макетов «из коробки».

В этой статье я покажу, как можно добавить гибкость в структуру формы, используя кастомные шаблоны.

Проблема

По умолчанию RJSF располагает все поля формы в одну колонку, растягивая их на всю ширину контейнера. Это может быть приемлемо для простых форм, но в реальных проектах зачастую возникает необходимость в более сложной структуре.

Как это выглядит без кастомного лэйаута

Допустим, у нас есть JSON-схема, описывающая простую форму:

{
  "title": "Заполните информацию о пользователе",
  "type": "object",
  "required": ["name", "username"],
  "properties": {
    "name": { "type": "string", "title": "ФИО" },
    "username": { "type": "string", "title": "Логин" },
    "email": { "type": "string", "title": "E-mail" },
    "telephone": { "type": "string", "title": "Телефон" },
    "telegram": { "type": "string", "title": "Telegram" },
    "date": { "type": "string", "format": "date", "title": "Дата рождения" },
    "bio": { "type": "string", "title": "О себе" },
    "city": { "type": "string", "title": "Город" }
  }
}

Если использовать её без дополнительных настроек, форма будет выглядеть следующим образом:

Как видно, все поля выстроены в один длинный список, что далеко не всегда удобно.

Для демонстрации я буду использовать RJSF совместно с Ant Design, но предложенный подход можно адаптировать для любой библиотеки компонентов с минимальными изменениями.

Решение

Чтобы реализовать многоколоночную структуру, сначала определимся со схемой, которая будет описывать расположение полей:

{
  "sections": [ // массив секций
    {
      "id": "string",
      "header": { // заголовок секции
        "title": "string",
        "align": "string",
        "heading_size": "number"
      },
      "blocks": [ // массив блоков в секции
        {
          "id": "string",
          "fields": { // список полей в блоке
            "fieldName": {
              "width": "number" // Ширина поля относительно блока. Не обязательный параметр, если не указана будет во всю ширину
            }
          },
          "width": "number" // Ширина блока. Не обязательный параметр, если не указана - 100%/число блоков в секции
        }
      ]
    }
  ]
}

RJSF поддерживает кастомные шаблоны (templates), которые позволяют изменять макет формы под свои нужды. Я буду использовать ObjectFieldTemplate, который отвечает за рендеринг контейнера для всех полей объекта. Он позволяет переопределять стандартное расположение элементов, задавая кастомную разметку.

Код ObjectFieldTemplate.tsx

import React, { useState, useEffect, useMemo } from 'react';
import { Typography, Col } from 'antd';
import './ObjectFieldTemplate.css';

type PropertyType = {
  content: any;
  name: string;
};

function ObjectFieldTemplate(props: any) {
  // properties нужны нам для вывода в конце формы полей, которые мы могли забыть перечислить в layout
  const [properties, setProperties] = useState<Record<string, PropertyType>>(
    {}
  );
  const layout = props.formContext.getLayout();

  useEffect(() => {
    const obj = (props.properties || []).reduce(
      (acc: any, curr: any) => ({
        ...acc,
        [curr.name]: { content: curr.content, name: curr.name },
      }),
      {}
    );
    setProperties(obj);
  }, [props.properties]);

  const gridLayout = useMemo(
    () =>
      layout
        ? layout.sections.map((section: any) => {
            return (
              <div key={section.id}>
                {section.header && (
                  <Typography.Title
                    level={section.header.heading_size || 4}
                    style={{
                      textAlign: section.header.align || 'center',
                    }}
                  >
                    {section.header.title}
                  </Typography.Title>
                )}
                <div className="layout__section">
                  {section.blocks.map((block: any) => {
                    return (
                      <div
                        key={`${section.id}-${block.id}`}
                        className="layout__block"
                        style={{
                          width: `${
                            block.width ? 100 / (24 / block.width) : 100
                          }%`,
                        }}
                      >
                        {Object.keys(block.fields).map((el: any) => {
                          const field = properties[el];
                          delete properties[el];
                          return field ? (
                            <Col
                              key={field.name}
                              data-field={field.name}
                              span={block.fields[el].width || 24}
                              style={{ padding: '0 8px' }}
                            >
                              {field.content}
                            </Col>
                          ) : null;
                        })}
                      </div>
                    );
                  })}
                </div>
              </div>
            );
          })
        : null,
    [properties, layout]
  );

  return (
    <div>
      {props.title ? (
        <Typography.Title level={3}>{props.title}</Typography.Title>
      ) : null}
      {props.description ? (
        <Typography.Text>{props.description}</Typography.Text>
      ) : null}

      {gridLayout}

      {/* поля, которые могли быть не указаны в layout */}
      {props.properties.map((el: any) =>
        properties[el.name] ? (
          <div key={el.name} style={{ padding: '0 8px' }}>
            {el.content}
          </div>
        ) : null
      )}
    </div>
  );
}

export default ObjectFieldTemplate;

Стили для ObjectFieldTemplate

.layout {
  display: flex;
  flex-wrap: wrap;
}

Вот как будет выглядеть лэйаут для моей формы:

{
  "sections": [
    {
      "id": "section1",
      "header": {
        "title": "Основная информация",
        "align": "center",
        "heading_size": 4
      },
      "blocks": [
        {
          "id": "block1",
          "fields": {
            "name": { "width": 16 },
            "username": { "width": 8 },
            "telegram": { "width": 8 },
            "email": { "width": 8 },
            "telephone": { "width": 8 }
          }
        }
      ]
    },
    {
      "id": "section2",
      "header": {
        "title": "Общие сведения",
        "align": "center"
      },
      "blocks": [
        {
          "id": "block2",
          "fields": { "date": {}, "city": {} },
          "width": 8
        },
        {
          "id": "block3",
          "fields": { "bio": {} },
          "width": 16
        }
      ]
    }
  ]
}

После применения кастомного шаблона форма выглядит совершенно по-другому:

В данном примере я использую Ant Design, в котором грид-система построена на 24 колонках. Для каждого элемента формы задаётся ширина в рамках 24-колоночной сетки и мы можем легко менять количество колонок для каждого блока, управляя этим параметром.

Итог

Используя react-jsonschema-form совместно с кастомными шаблонами, мы можем значительно расширить его возможности. Теперь наша форма больше не ограничена одной колонкой, а её макет можно легко настроить, изменяя всего лишь схему лэйаута.

Ссылка на GitHub