Pull to refresh

Как сделать много форм, не сделав ни одной

Reading time8 min
Views10K

Меня зовут Виталий Павленко, я фулстек-разработчик в Профи. Расскажу, как мы построили универсальный сервис по созданию форм в продукте.

Мы постоянно имеем дело с формами: регистрация, заполнение анкеты, составление отзыва. Первое, что нам хочется сделать как разработчикам,— максимально выделить общие компоненты, чтобы как можно меньше дублировать код. 

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

На самом деле есть другое решение. И об этом моя статья.

Что хотим получить

Наша главная цель — ускорить внедрение форм в продуктовых командах и сократить количество написанного кода (и на фронтенде, и на бэкенде). 

Ответственность распределяем так:

Фронтенд. На фронтенде храним внешний вид элементов формы, т.е. дёргаем type элемента и рисуем как надо, бэкенд при этом ничего не знает о внешнем виде. Приложение просто получает список элементов, рисует, а потом отправляет введённые значения пользователей обратно на бэк.

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

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

Выше на схеме идеальная картинка, к которой мы стремимся. Сюда ещё можно добавить админку для составления таких форм. Как обычно, ответственность может переползать на соседние зоны: вместо базы данных формы могут собираться сразу на бэкенде, а с бэкенда отдавать некоторые стили в параметрах элементов на фронт.

Как устроены наши формы

Для примера возьмём форму регистрации психологов и попробуем выделить модули. В реальности форма немного сложнее, но нам этого хватит для понимания подхода.

Какие элементы мы видим: 

  • RadioGroup (блок с радиокнопками), 

  • Text (простой текст «Чтобы откликаться…»), 

  • Row (обёртка, где несколько элементов подряд), 

  • Input (поле ввода).

Эти элементы условно можно разделить на несколько категорий по предназначению:

Категория

Элементы

Назначение

Статические элементы

Text

Отображают неизменяемую информацию, обычно дополняют изменяемые элементы

Элементы разметки

Row

Формируют разметку, добавляют отступы

Изменяемые элементы

Input, RadioGroup

Ждут действий от пользователя, валидируют полученную информацию, выводят ошибки

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

Ещё в форме есть заголовок и кнопки управления («Назад», «Вперёд», «Отправить»). Но эти вещи нам не нужны для составления композиции, так как они плюс-минус статичные. Но мы будем получать их с бэкенда, чтобы максимально освободить фронтенд от бизнес-логики.

Делаем красиво

Начнём с фронтенда. На этом этапе нужно сверстать все кусочки нашей формы, чтобы в дальнейшем сделать из них любую композицию.

Создаём универсальный компонент Element, который будет подхватывать нужный компонент, в зависимости от типа:

// Element.tsx

import {FC} from 'react';
import {Text, Input, Row, RadioGroup} from '../elements';
import {ElementVariant, ElementType} from '../types';

export type ElementProps = {
  element: ElementVariant;
};

export const Element: FC<ElementProps> = ({element}) => {
  switch (element.type) {
    case ElementType.input:
      return <Input {...element} />;
    case ElementType.text:
      return <Text {...element} />;
    case ElementType.radioGroup:
      return <RadioGroup {...element} />;
    case ElementType.row:
      return <Row {...element} />;
    default:
      return null;
  }
};

Добавляем типы. Скажем, что у каждого элемента будут обязательны id и type, а дополнительные поля опциональны:

// types.ts

export type ElementBase<Type, ExtraProps> = {
  type: Type;
  id: string;
} & ExtraProps;

export enum ElementType {
  text = 'Text',
  input = 'Input',
  radioGroup = 'RadioGroup',
  row = 'Row',
}

export type ElementText = ElementBase<
  ElementType.text,
  {
    text: string;
  }
>;

export type ElementInput = ElementBase<
  ElementType.input,
  {
    value?: string;
    placeholder: string;
  }
>;

export type ElementRadioGroup = ElementBase<
  ElementType.radioGroup,
  {
    activeId?: string;
    items: {
      id: string;
      value: string;
    }[];
  }
>;

export type ElementRow = ElementBase<
  ElementType.row,
  {
    element: ElementVariant;
  }
>;

export type ElementVariant =
  | ElementText
  | ElementInput
  | ElementRadioGroup
  | ElementRow;

Обратите внимание, что ElementRow содержит вложенный элемент, который будет вкладываться рекурсивно. Эта опция очень полезна для компонентов обёрток или более высокоуровневых компонентов, которые создают композиции особым образом.

Теперь нужно сделать кнопки переключения шагов.

// ProfiForm.tsx

// Навигация приходит с бэкенда примерно в таком виде:
// navigations: [
// {
//   type: 'secondary',
//   text: 'Назад',
//   stepId: 'specialization',
// },
// {
//   type: 'primary',
//   text: 'Продолжить',
//   stepId: 'experience',
// },
// ],


 data.navigations.map(button => {
   return (
     <Button
       dataType={button.type}
       onClick={() => getData(button.stepId)}
     >
       {button.text}
     </Button>
   );
 });

Подразумевается, что по клику на кнопку навигации мы просто перезапрашиваем данные для текущей страницы с новым id шага.

Управляем состоянием

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

Первым делом оборачиваем нашу форму в FormProvider и передаём в него методы из useForm(). Эта библиотека позволит эффективно работать с состоянием формы и устанавливать предзаполненные значения, полученные с бэкенда.

// ProfiForm.tsx

import React, {useEffect, useState} from 'react';
import {useForm, FormProvider} from 'react-hook-form';

export const ProfiForm = () => {
  const form = useForm();

  return (
    <FormProvider {...form}>
      {/* здесь все элементы, навигация и т.д. */}
    </FormProvider>
  );
};

Теперь посмотрим на примере Input, как работать с заполненным значением. Главное, чтобы этот элемент находился внутри провайдера:

// Input.tsx

import {FC} from 'react';
import {useFormContext} from 'react-hook-form';
import {ElementInput} from '../../types';

export const Input: FC<ElementInput> = ({placeholder, value, id}) => {
  const {register} = useFormContext();
  return <input placeholder={placeholder} {...register(id, {value})} />;
};

Состояние формы можно будет достать в любой момент. Например, если id нашего инпута будет выше university и написать в инпуте «Какой-то универ», то получим такое состояние:

// ProfiForm.tsx

import React, {useEffect, useState} from 'react';
import {useForm, FormProvider} from 'react-hook-form';

export const ProfiForm = () => {
  const form = useForm();

	console.log(form.getValues()); // { university: 'Какой-то универ', ... }

  return (
    <FormProvider {...form}>
      {/* здесь все элементы, навигация и т.д. */}
    </FormProvider>
  );
};

А дальше понятно, что с этим делать 🙂. К примеру, отправлять на сервер текущее состояние в нужные моменты. При этом вы сами решаете, когда отправлять изменения на бэкенд:

  • каждый раз при вводе символа;

  • при расфокусе инпута;

  • после переключения на следующий шаг;

  • или ещё какие-то варианты.

Мы решили сохранять изменения после переключения на следующий шаг.

Идём в бэкенд

Мы используем GraphQL на бэкенде, он хорошо подходит под такой тип задач, но сейчас не будем усложнять пример.

Одна из основных задач сервиса — формировать нужную структуру данных, отдавая такой ответ на фронт:

const response = {
  title: 'Какое у вас высшее образование?',
  elements: [
    {
      id: 'education-info-row',
      type: 'Row',
      element: {
        id: 'education-info',
        type: 'Text',
        text: 'Чтобы откликаться на заказы по психологии, нужно психологическое или медицинское высшее образование.',
      },
    },
    {
      id: 'university-row',
      type: 'Row',
      element: {
        id: 'university',
        type: 'Input',
        placeholder: 'Вуз',
      },
    },
    {
      id: 'speciality-row',
      type: 'Row',
      element: {
        id: 'speciality',
        type: 'Input',
        placeholder: 'Специальность',
      },
    },
  ],
  navigations: [
    {
      type: 'secondary',
      text: 'Назад',
      stepId: 'specialization',
    },
    {
      type: 'primary',
      text: 'Продолжить',
      stepId: 'experience',
    },
  ],
};

Можно по-разному сформировать такой объект. Например, сделать базовый класс и классы для каждого элемента. Посмотрим на примере того же инпута:

// server side

class BaseElement {
  constructor(id) {
    this.id = id;
  }

  setValue(value) {
    this.value = value;
    return this;
  }
}

class InputElement extends BaseElement {
  type = 'Input';

  setPlaceholder(placeholder) {
    this.placeholder = placeholder;
    return this;
  }
}

Тогда можно формировать предыдущий ответ так (предварительно получив контент из базы данных или из другого сервиса):

const response = {
  title: 'Какое у вас высшее образование?',
  elements: [
    new RowElement('education-info-row').setElement(
      new TextElement('education-info').setText(
        'Чтобы откликаться на заказы по психологии, нужно психологическое или медицинское высшее образование.',
      ),
    ),
    new RowElement('university-row').setElement(
      new InputElement('university').setPlaceholder('Вуз').setValue('МГУ'),
    ),
    new RowElement('speciality-row').setElement(
      new InputElement('speciality').setPlaceholder('Специальность'),
    ),
  ],
  navigations: [
    {
      type: 'secondary',
      text: 'Назад',
      stepId: 'specialization',
    },
    {
      type: 'primary',
      text: 'Продолжить',
      stepId: 'experience',
    },
  ],
}

Обратите внимание на setValue(): значение «МГУ» пойдёт как предзаполненное на фронтенд. Сюда можно подложить значение, предварительно получив его из базы данных (табличка state в примере).

Используем сервис на фронтенде

Теперь собираем всё вместе:

// ProfiForm.tsx

import React, {useEffect, useState} from 'react';
import {useForm, FormProvider} from 'react-hook-form';
import {Form, Title, Elements, Button, Footer} from './styled';
import {ElementVariant} from './types';
import {Element} from './Element';

type Button = {
  type: 'secondary' | 'primary';
  text: string;
  stepId: string;
};

type FormData = {
  title: string;
  elements: ElementVariant[];
  navigations?: Button[];
};

export const ProfiForm = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [data, setData] = useState<FormData | null>(null);
  const form = useForm();

  const getData = (stepId: string | null) => {
    setIsLoading(true);
    fetch('/api/form', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        stepId: stepId,
        values: form.getValues(),
      }),
    })
      .then(response => response.json())
      .then(data => setData(data))
      .finally(() => setIsLoading(false));
  };

  useEffect(() => {
    getData(null);
  }, []);

  if (isLoading) {
    return <div>Lading...</div>;
  }

  if (!data) {
    return <div>Нет данных</div>;
  }

  return (
    <FormProvider {...form}>
      <Form>
        <Title>{data.title}</Title>
        <Elements>
          {data.elements.map(element => (
            <Element key={element.id} element={element} />
          ))}
        </Elements>
        {data.navigations && (
          <Footer>
            {data.navigations.map((button, index) => {
              return (
                <Button
                  key={index}
                  dataType={button.type}
                  onClick={() => getData(button.stepId)}
                >
                  {button.text}
                </Button>
              );
            })}
          </Footer>
        )}
      </Form>
    </FormProvider>
  );
};

В итоге у нас получился универсальный компонент ProfiForm, который сам ходит в сервис и рисует любую форму по полученной структуре данных! Достаточно добавить новый параметр в эндпойнт (formType) и обрабатывать любые формы в любом месте приложения, просто вызвав компонент <ProfiForm type=”psychology” /> или <ProfiForm type=”registration” />. Главное, не забыть добавить этот пропс в эндпойнт сервиса.

Ниже демка нашего примера. Или вот проект на гитхабе, если хотите запустить демо локально.

Спасибо за внимание. Надеюсь, наш опыт будет полезен для вас!

Tags:
Hubs:
Total votes 6: ↑5 and ↓1+4
Comments17

Articles