Исходное сообщение:



Месяц назад мы опубликовали статью Dry-rb и Trailblazer Reform, в которой наш коллега Дмитрий Воронов объяснил, что стандартный подход Rails перестал соответствовать требованиям компании и что стек Dry-rb / Reform был принят в JetRockets.

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

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

Таким образом, если и когда логика на сервере станет более сложной, мы решили написать наш клиентский код по-другому. Вариантов было много, но так как мы давно планировали использовать React в продакшене, то наш выбор пал на него. Мы решили использовать Redux для хранения данных и Redux-Form для работы с формами.

Мы используем гемы Webpacker и React-rails для интеграции React в Ruby on Rails. Поэтому мы не будем описывать этот процесс, а сосредоточимся на примерах.

Форма рендеринга

Начнем с отрисовки необходимого компонента в index.html.slim:

= react_component("settings/plans", react_container_props)

React_container_props - это простой helper_method в контроллере, который возвращает объект, состоящий из данных, которые потребуются в реакции.

def react_container_props
  super.reverse_merge({
    refs: {
      optionsForApplicationTypes: [...],
      optionsForEmployeeRoles: [...],
      availablePlanChannelTypes: [...],
      availablePlanChannelCalculationMethods: [...]
    }
  }).as_json.as_camelize
end

Индексный файл настроек / планов компонента React выглядит так:

import React from 'react';
import { Provider } from 'react-redux';
import { syncHistoryWithStore } from 'react-router-redux'
import { Router, Route, IndexRoute, browserHistory } from 'react-router'
import configureStore from './store';
...
...
function PlansIndex(props) {
  const { refs } = props;
  const store = configureStore(refs);
  const history = syncHistoryWithStore(browserHistory, store);
  return (
    <Provider store={store}>
      <Router history={history}>
        <Route path="/settings/plans" component={PlansLayout}>
          <Route path="new" component={(props) => (<PlanNew {...props} refs={refs}/>)} />
          <Route path=":id/edit" component={(props) => (<PlanEdit {...props} refs={refs}/>)} />
        </Route>
      </Router>
    </Provider>
  )
}
export default PlansIndex;

Refs - это данные, которые мы передали от контроллера с помощью helper_methodreact_container_props. Мы используем его как initialState для нашего магазина.

Функции PlanNew и PlanEdit - это просто оболочки для PlansFormContainer:

import React from "react";
import PlansFormContainer from "../shared/form/container";
function PlanNew(props) {
  return (
    <div className="wrapper-fluid">
     <PlansFormContainer {...props} />
    </div>
  )
}
  
export default PlanNew;

Это «умный» компонент, который подготавливает параметры для нашей формы:

// ../shared/form/container
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import {
...
} from "./selectors";
import * as actions from "components/settings/plans/redux/actions/planEditActions";
import PlansForm from "./components/Form";
const mapStateToProps = (state, props) => ({
  optionsForApplicationTypes: state.plans.refs.optionsForApplicationTypes,
  optionsForEmployeeRoles: [...],
  availableProducts: [...],
  optionsForPlanChannelTypes: [...],
  ...,
  optionsForEmployees: optionsForEmployeesFromCollection(
state.plans.refs.availableEmployees),
  selectedEmployee: selectedEmployeeSelector(state)
});
const mapDispatchToProps = dispatch => ({
  actions: bindActionCreators(actions, dispatch)
});
export default connect(mapStateToProps, mapDispatchToProps)(PlansForm);

Часть данных, например optionsForApplicationTypes, представляет собой объекты из магазина как есть. Другие селекторы, например selectedEmployee, представляют собой селектор функций, который выбирает и подготавливает необходимые формы данных. Это выглядит так:

// selectors.js
import { createSelector } from "reselect";
...
export const selectedEmployeeSelector = createSelector(
  planFormSelector,
  refsSelector,
  (planForm, refs) => {
    const employeeId = parseInt(get(planForm, "values.plan.employee_id"));
    const employees = refs.availableEmployees;
    return find(employees, { id: employeeId });
  }
);
...

Здесь мы используем фреймворк Reselect.

Перейдем к форме. Включите Redux-Form, как написано в документации компонента.

// Form.jsx
import React, { Component } from "react";
...
import submit from "./submit";
...
class PlanForm extends Component {
  constructor(props) {
    super(props);
  }
  // ...
  render() {
    return (
      <Form onSubmit={handleSubmit(submit)}>
        {/* form body */}
      </Form>
    );
  }
}
PlanForm = reduxForm({
  form: "planForm"
})(PlanForm);
const selector = formValueSelector("planForm");
PlanForm = connect(state => {
  const id = selector(state, "plan[id]");
  const initialData = state.plans.initialData;
  ...
  return ({
    initialValues: initialData,
    plan: {
      id,
      ...
    }
    ...
  })
})(PlanForm);
export default PlanForm;

Мы указываем в connect, значения каких полей формы мы хотим выбрать, и помещаем их в свойства нашей формы. Мы также указываем, откуда форма должна брать начальные данные: state.plans.initialData.

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

componentDidMount() {
  const { fetchPlanRequest } = this.props.actions;
  const planId = this.props.params.id;
  if (planId) {
    fetchPlanRequest(planId);
  }
}

А если в params есть id, значит, мы на странице редактирования и необходимо взять данные текущего объекта с сервера.

// components/settings/plans/redux/actions/planEditActions.js
export function fetchPlanRequest(id, params = {}) {
  return (dispatch, getState) => {
     axios.get(
       id ? 
       Routes.edit_settings_plan_path(id, { ...params, format: "json" }) 
       :
       Routes.new_settings_plan_path({ ...params, format: "json" }),
       {
         withCredentials: true
       }
     ).then(res => {
       ...
       dispatch(fetchPlanData(res.data));
       ...
     });
  };
}

Действие в контроллере выглядит так (new выглядит абсолютно так же, за исключением метода авторизации и имени формы в GlobalContainer):

def edit
  authorize resource_plan, :update?
  form = GlobalContainer['plan.forms.update_plan_form_class'].new(resource_plan)
  form.prepopulate!
    respond_to do |format|
      format.json {
        render json: {
          plan: Plan::EditPlanRepresenter.new.(form.sync)
        }
      }
      format.html {
        render :index
      }
   end
end

Код представителя:

class Plan::EditPlanRepresenter < BaseRepresenter
  def call(plan)
    {
      id: plan.id,
      application_type: plan.application_type,
      start_date: plan.start_date,
      end_date: plan.end_date,
      product_id: plan.product_id,
      employee_role: plan.employee_role,
      employee_id: plan.employee.try(:id),
      channels: render_channels(plan.channels)
    }
  end
private
  def render_channels(channels)
    channels.map do |channel|
      {
        ...channels fields...
      }
    end
  end
  
  def render_tiers(tiers)
    tiers.map do |tier|
      {
        ...tiers fields...
      }
    end
  end
end

Если данные успешно загружены, reducer помещает их в state.plans.initialData, откуда форма будет брать начальные значения.

Визуальное представление

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

Нам не удалось найти готовых решений для интеграции SemanticUI и Redux-Form. Но в документации последнего достаточно информации о том, как работать со сторонними компонентами.

Достаточно обернуть компоненты SemanticUI в компонент Field из Redux-Form. Ниже приведен пример реализации оболочки для раскрывающегося списка; propTypes и defaultProps опускаются:

// From.jsx
import SelectUI from "components/shared/inputs/selectUI";
...
<Field
  id="plan_employee_role"
  required={true}
  name="plan[employee_role]"
  label={i18n.activerecord.attributes.plan.employee_role}
  options={optionsForEmployeeRoles}
  action={changeEmployeeRole}
  component={SelectUI}
  includeBlank={true}
/>
...
// components/shared/inputs/selectUI.js
import { Form, Select } from "semantic-ui-react";
...
const propTypes = {
  ...
};
const defaultProps = {
  ...
};
const SelectUI = props => {
  return (
    <Form.Field required={required}>
      {label && <label>{label}</label>}
        <Select
          fluid
          label={label}
          selectOnBlur={false}
          closeOnBlur
          options={optionsForSelect(options)}
          placeholder={placeholder}
          required={required}
          value={input.value}
          error={error && error.length > 0}
          onChange={(e, data) => {
            input.onChange(data.value);
            if (action) {
              action(e, data);
            } else {
              return true;
            }
          }}
        />
        {error && <span className="errored">{error}</span>}
    </Form.Field>
 );
};
SelectUI.propTypes = propTypes;
SelectUI.defaultProps = defaultProps;
export default SelectUI;

Проверка и отправка формы

В Redux-Form реализована возможность проверки данных несколькими способами. Мы решили использовать Submit Validation. Оставляем только требовать валидации для клиента, а остальные проверки на стороне сервера.

Как это работает? Мы указываем функцию submit в компоненте формы, которая возвращает обещание в качестве реквизита.

<Form onSubmit={handleSubmit(submit)}

Submit код функции

// submit.js
import { SubmissionError } from "redux-form";
import axios from "axios";
import _ from "lodash";
import { browserHistory } from "react-router";
let Routes = require("utils/routes");
const prepareErrors = errors => {
  // conver the object with errors
  ...
};
const submit = (values, dispatch, props) => {
  const url = values.plan.id
    ? Routes.settings_plan_path(values.plan.id, { format: "json" })
    : Routes.settings_plans_path({ format: "json" });
  const method = values.plan.id ? "patch" : "post";
  return axios
    .request({
      url: url,
      method: method,
      data: values,
      withCredentials: true,
      xsrfHeaderName: "X-CSRF-Token"
    }).then(res => {
      // Data was saved. Make a redirect...
      const location = res.headers.location;
      browserHistory.push(location);
    })
    .catch(error => {
      // Error was catched
      const preparedErrors = prepareErrors(error.response.data.payload.errors);
      if (error.response.data.payload.errors) {
        throw new SubmissionError({
          plan: preparedErrors
        });
      }
    });
};
export default submit;

При нажатии на кнопку отправки на сервер отправляется запрос на публикацию и исправление. Если данные успешно сохранены, вы можете перенаправить пользователя по местоположению, которое поступает с сервера. Если произошла ошибка, вы должны принять этот входящий объект с ошибками и передать его функции SubmissionError.

Прежде чем передавать ошибки функции SubmissionError, вы должны их преобразовать. По умолчанию объект в следующей форме пришел с сервера:

{
  field_1: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
  field_2: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
  field_3: {
    field_3_1: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
    field_3_2: ['Error 1 text', 'Error 2 text', ..., 'Error n text'],
    ...
  }
}

PrepareErrors только что заменил ошибки массива первым из них:

{
  field_1: 'Error 1 text',
  field_2: 'Error 1 text',
  field_3: {
    field_3_1: 'Error 1 text',
    field_3_2: 'Error 1 text',
    ...
  }
}

Redux-Form именно в таком виде ожидает получить объектные ошибки.

Пример метода создания в контроллере:

def create
  authorize resource_plan, :create?
  command = GlobalContainer['plan.services.create_plan_command'] #Plan::CreatePlan.new
  respond_to do |format|
    format.json {
      command.call(resource_plan, params[:plan]) do |m|
        m.success do |plan|
          flash[:notice] = t('messages.created', resource_name: Plan.model_name.human)
          render json: { id: plan.id}, status: :ok, location: settings_plan_path(plan)
        end
        
        m.failure do |form|
          render json: {
            status: :failure,
            payload: { errors: form.react_errors_hash }
          }, status: 422
        end
      end
    }
  end
end

React_errors_hash метод формы формирует ошибки объекта.

Заключение

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

Redux-Form - действительно мощный инструмент для работы с формами. И я очень рад, что он разрабатывается и поддерживается Эриком Расмуссеном не только как компонент для React, но теперь и как фреймворк Final Form с нулевой зависимостью.