Исходное сообщение:
Месяц назад мы опубликовали статью 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 с нулевой зависимостью.