Pull to refresh
79.09
Mindbox
Автоматизация маркетинга

Как мы перестали бояться тикетов на UI

Reading time 37 min
Views 18K
Всем привет.
Прошло уже больше года с тех пор, как мы начали использовать ReactJS в разработке. Наконец пришел момент для того, чтобы поделиться тем, насколько счастливее стала наша компания. В статье я собираюсь рассказать о причинах, которые побудили нас использовать эту библиотеку и о том, как мы это делаем.

Зачем всё это


Мы — маленькая компания, наш штат составляет порядка 50 человек, 20 из которых разработчики. Сейчас у нас 4 команды разработки, в каждой из которых сидит по 5 fullstack разработчика. Но одно дело называть себя fullstack-разработчиком, а другое — действительно хорошо разбираться в тонкостях работы SQL Server'а, ASP.NET, разработке на C#, OOP, DDD, знать HTML, CSS, JS и уметь этим всем разумно пользоваться. Конечно каждый разработчик тяготеет к чему-то своему, но все мы, так или иначе, специалисты именно в разработке на .NET и 90% кода мы пишем на C#.
Наш продукт — система автоматизации маркетинга, — подразумевает большой объем настроек для каждого конкретного клиента. Для того, чтобы наши менеджеры могли заниматься настройкой продукта под клиентов, есть административный сайт, в котором можно заводить рассылки, создавать триггеры и другие механики, кастомизировать сервисы и многое другое. Этот административный сайт содержит много различного нетривиального UI'а, и чем более тонкие моменты мы даём настраивать, чем большее количество фич мы выпускаем в продакшн, тем более интересным UI становится.

Создание триггера


Фильтр по категориям продуктов


Как же мы справлялись с разработкой такого UI'а раньше? Справлялись мы плохо. В основном, отделывались отрисовкой на сервере кусков HTML'а, которые получали ajax'ом. Либо просто на событиях, используя JQuery. Для пользователя это обычно выливалось в постоянные подгрузки, прелоадеры на каждый чих и странные баги. С точки зрения разработчика это были самые настоящие макароны, которых все боялись. Любой тикет на UI на планировании сразу получал оценку L и выливался в тонну батхёрта при написании кода. И, разумеется, было много багов, связанных с таким UI'ем. Происходило это так: в первой реализации допускалась какая-то мелкая ошибка. А при починке неминуемо разваливалось что-то другое, потому что тестов на это чудо не было.
Пример из жизни. Перед вами страница создания операции. Не вдаваясь в подробности по бизнесу скажу только, что операции у нас — это что-то вроде REST-сервисов, которые могут использовать подрядчики наших клиентов. У операции есть ограничения на доступность согласно этапам регистрации потребителей, и для того, чтобы это настраивать, был вот такой контрол:
Создание операции


А вот старый код этого контролла:
Код контолла указания доступности операции
Кусочек вьюхи
<h2 class="column-header">
        <span class="link-action"
                  data-event-name="ToggleElements"
                  data-event-param='{"selector":"#WorkFlowAllowance", "callback": "toggleWorkflowAvailability"}'>
                Доступность на этапах регистрации
        </span>
</h2>
@Html.HiddenFor(m => m.IsAllowedForAllWorkflow, new { Id = "IsAllowedForAllWorkflow" })
<div id="WorkFlowAllowance" class="@(Model.IsAllowedForAllWorkflow ? "none" : string.Empty) row form_horizontal">
        <table class="table table_hover table_control @(Model.OperationWorkflowAllowances.Any() ? String.Empty : "none")" id="operationAllowanceTable">
                <thead>
                        <tr>
                                <th>Механика регистрации</th>
                                <th>Этап</th>
                        </tr>
                </thead>
                <tbody>
                        @Model.OperationWorkflowAllowances.Each(
                        @<tr>
                                <td>
                                        @item.Item.WorkflowDisplayName
                                        <input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[@(item.Index)].WorkflowName" value="@item.Item.WorkflowName" />
                                        <input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[@(item.Index)].WorkflowDisplayName" value="@item.Item.WorkflowDisplayName" />
                                        <input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[@(item.Index)].Id" value="@item.Item.Id" />
                                </td>
                                <td>
                                        <button class="cell-grid__right button button_icon-only button_red removeOperationAllowance"><span class="icon icon_del"></span></button>
                                        <span class="cell-grid__wraps">@(item.Item.StageName ?? "Любой")</span>
                                        <input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[@(item.Index)].StageName" value="@item.Item.StageName" />
                                        <input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[@(item.Index)].StageDisplayName" value="@item.Item.StageDisplayName" />
                                </td>
                        </tr>)
                </tbody>
        </table>
        <div class="col col_462">
                <div class="form-group form-group_all">
                        
                </div>
                @if (Model.WorkFlows.Any())
                {
                        <div>
                                <div class="form-group">
                                        <label class="form-label"><span>Механика регистрации</span></label>
                                        @Html.DropDownList("WorkflowList", Model.WorkFlows, new Dictionary<string, object>
                                        {
                                                { "class", "form-control select2 w470" },
                                                { "data-placeholder", "Выберите из списка" },
                                                { "id", "workflowList" },
                                                { "disabled", "disabled" }
                                        })
                                </div>
                                <div class="form-group">
                                        <div class="form-list">
                                                <input id="isAllowedForAllStagesForCurrentWorkflow" type="checkbox" name="StageMechanicsRegistratioName" autocomplete="off">
                                                <label for="isAllowedForAllStagesForCurrentWorkflow">Доступна на любом этапе механики <span id="exceptAnonymus"></span><span id="workflowName"></span></label>
                                        </div>
                                </div>
                                <div class="form-group">
                                        <label class="form-label"><span>Этап</span></label>
                                        @Html.DropDownList("WorkflowStageList", new SelectListItem[0], new Dictionary<string, object>
                                        {
                                                { "class", "form-control select2 w470" },
                                                { "data-placeholder", "Выберите из списка" },
                                                { "id", "workflowStageList" },
                                                { "disabled", "disabled"}
                                        })
                                </div>
                                <div class="form-group">
                                        <button class="button button_blue" id="addOperationAllowance">Добавить доступность</button>
                                </div>
                        </div>
                }
                else
                {
                        @: Механики регистрации не зарегистрированы
                }
        </div>

</div>

А вот js, который заставлял эту вьюху работать (я не преследовал цель показать код, который можно запустить, я просто показываю, как всё было печально):
function initOperationAllowance(typeSelector)
{
        $('#workflowList').prop('disabled', false);
        $('#workflowList').trigger('change');

        if ($(typeSelector).val() == 'PerformAction') {
                $('#exceptAnonymus').html('(кроме анонимных)');
        } else {
                $('#exceptAnonymus').html('');
        }
}

function toggleWorkflowAvailability() {
        var element = $("#IsAllowedForAllWorkflow");
        $('#operationAllowanceTable tbody tr').remove();
        parameters.selectedAllowances = [];
        return  element.val().toLowerCase() == 'true' ? element.val(false) : element.val(true);
}

function deleteRow(row)
{
        var index = getRowIndex(row);
        row.remove();
        parameters.selectedAllowances.splice(index, 1);

        $('#operationAllowanceTable input').each(function () {
                var currentIndex = getFieldIndex($(this));
                if (currentIndex > index) {
                        decrementIndex($(this), currentIndex);
                }
        });

        if (parameters.selectedAllowances.length == 0) {
                $('#operationAllowanceTable').hide();
        }
}

function updateWorkflowSteps(operationType) {
        var workflow = $('#workflowList').val();
        if (workflow == '') {
                $('#isAllowedForAllStagesForCurrentWorkflow')
                        .prop('checked', false)
                        .prop('disabled', 'disabled');
                refreshOptionList(
                        $('#workflowStageList'),
                        [{ Text: 'Выберите из списка', Value: '', Selected: true }]
                );
                $('#workflowStageList').trigger('change').select2('enable', false);
                return;
        }

        var url = parameters.stagesUrlTemplate + '?workflowName=' + workflow + '&OperationTypeName=' + operationType;
        
        $.getJSON(url, null, function (data) {

                $('#isAllowedForAllStagesForCurrentWorkflow')
                        .prop('checked', false)
                        .removeProp('disabled');

                refreshOptionList($('#workflowStageList'), data);
                $('#workflowStageList').trigger('change').select2('enable', true);
        });
}

function refreshOptionList(list, data) {
        list.find('option').remove();

        $.each(data, function (index, itemData) {

                var option = new Option(itemData.Text, itemData.Value, null, itemData.Selected);

                list[0].add(option);
        });
}

function AddRow(data) {

        var rowsCount = $('#operationAllowanceTable tr').length;
        var index = rowsCount - 1;

        var result =
                '<tr ' + (rowsCount % 2 != 0 ? 'class="bgGray">' : '>') +
                        '<td>' +
                                '{DisplayWorkflowName}' +
                                '<input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[' + index + '].WorkflowName" value="{WorkflowName}"/>' +
                                '<input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[' + index + '].Id" value=""/>' +
                                '<input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[' + index + '].WorkflowDisplayName" value="{DisplayWorkflowName}"/>' +
                        '</td>' +
                        '<td>' +
                                '<button class="cell-grid__right button button_icon-small button_red removeOperationAllowance"><span class="icon icon_del"></span></button>' +
                                '<span class="cell-grid__wraps">{DisplayStageName}</span>' +
                                '<input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[' + index + '].StageName" value="{StageName}"/>' +
                                '<input type="hidden" name="OperationTypeViewModel.OperationWorkflowAllowances[' + index + '].StageDisplayName" value="{DisplayStageName}"/>' +
                '</td>' +
                '</tr>';

        for (key in data) {
                result = result.replace(new RegExp('{' + key + '}', 'g'), data[key]);
        }

        $('#operationAllowanceTable').show().append(result);
}

function IsValidForm() {
        var result = ValidateList($('#workflowList'), 'Вы не выбрали механику регистрации') &
                ValidateListWithCheckBox($('#workflowStageList'), $('#isAllowedForAllStagesForCurrentWorkflow'), 'Вы не выбрали этап механики регистрации');

        if (!result)
                return false;

        var workflowName = $('#workflowList').val();
        var stageName = '';

        if (!$('#isAllowedForAllStagesForCurrentWorkflow').is(':checked'))
        {
                stageName = $('#workflowStageList').val();
        }

        hideError($('#workflowList'));
        hideError($('#workflowStageList'));

        for (var i = 0; i < parameters.selectedAllowances.length; i++)
        {
                if (parameters.selectedAllowances[i].workflow == workflowName &&
                        parameters.selectedAllowances[i].stage == stageName)
                {
                        if (stageName == '')
                        {
                                showError($('#workflowList'), 'Доступность на этой механике регистрации уже указана');
                        }
                        else
                        {
                                showError($('#workflowStageList'), 'Доступность на этом этапе уже указана');
                        }
                        result = false;
                }
                else if (parameters.selectedAllowances[i].workflow == workflowName &&
                        parameters.selectedAllowances[i].stage == '') {
                        showError($('#workflowList'), 'Доступность на этой механике регистрации уже указана');
                        result = false;
                }
        }

        return result;
}

function ValidateList(field, message) {
        if (field.val() == "") {
                showError(field, message);
                return false;
        }

        hideError(field);

        return true;
}

function ValidateListWithCheckBox(field, checkBoxField, message) {
        if (!checkBoxField.prop('checked')) {
                return ValidateList(field, message);
        }

        hideError(field);
        return true;
}

function showError(field, message) {
        if (typeof (message) === 'undefined') {
                message = 'Поле обязательно для заполнения';
        }

        field.addClass('input-validation-error form-control_error');
        field.parent('.form-group').find('div.tooltip-error').remove();
        field.closest('.form-group').append(
                '<div class="tooltip-icon tooltip-icon_error"><div class="tooltip-icon__content">' +
                '<strong>Ошибка</strong><br>' + message + '</div></div>');
}

function hideError(field) {
        field.removeClass('input-validation-error form-control_error');
        field.parent('.form-group').find('div.tooltip-icon_error').remove();
}

function getRowIndex(row) {
        return getFieldIndex(row.find('input:first'));
}

function getFieldIndex(field) {
        var name = field.prop('name');

        var startIndex = name.indexOf('[') + 1;
        var endIndex = name.indexOf(']');

        return name.substr(startIndex, endIndex - startIndex);
}

function decrementIndex(field, index) {
        var name = field.prop('name');
        var newIndex = index - 1;
        field.prop('name', name.replace('[' + index + ']', '[' + newIndex + ']'));
}

function InitializeWorkflowAllowance(settings) {
	$(function() {
			parameters.selectedAllowances = settings.selectedAllowances;

			initOperationAllowance(parameters.typeSelector);

			$('#workflowList').change(function () {
					updateWorkflowSteps($(parameters.typeSelector).val());
			});

			$('#addOperationAllowance').click(function (event) {
					event.preventDefault();

					if (IsValidForm()) {
							var data = {
									'StageName': $('#workflowStageList').val(),
									'WorkflowName': $('#workflowList').val(),
							};

							if ($('#isAllowedForAllStagesForCurrentWorkflow').is(':checked')) {
									data.DisplayWorkflowName = $('#workflowList option[value=' + data.WorkflowName + ']').text();
									data.DisplayStageName = 'Любой';
									data.StageName = '';
							}
							else {
									data.DisplayWorkflowName = $('#workflowList option[value=' + data.WorkflowName + ']').text();
									data.DisplayStageName = $('#workflowStageList option[value=' + data.StageName + ']').text();
							}

							AddRow(data);

							if (data.StageName == '') {
									var indexes = [];

									// Нужно удалить уже добавленные этапы
									for (var i = 0; i < parameters.selectedAllowances.length; i++) {
											if (parameters.selectedAllowances[i].workflow == data.WorkflowName) {
													indexes.push(i);
											}
									}

									$("#operationAllowanceTable tbody tr").filter(function (index) {
											return $.inArray(index, indexes) > -1;
									}).each(function () {
											deleteRow($(this));
									});
							}

							parameters.selectedAllowances.push({
									workflow: data.WorkflowName,
									stage: data.StageName
							});

							$("#workflowList").val('').trigger('change');
							updateWorkflowSteps($(parameters.typeSelector).val());
					}
			});

			$('#isAllowedForAllStagesForCurrentWorkflow').click(function () {
					if ($(this).is(":checked")) {
							$('#workflowStageList').prop('disabled', 'disabled');
					}
					else {
							$('#workflowStageList').removeProp('disabled');
					}
			});

			$('#operationAllowanceTable').on('click', 'button.removeOperationAllowance', function (event) {
					var row = $(this).parent().parent();
					setTimeout(function () {
							deleteRow(row);
					}, 20);
					event.preventDefault();
			});
	});



Новая надежда


В какой-то момент мы поняли, что так жить больше нельзя. После некоторого обсуждения мы пришли к выводу, что нужен человек со стороны, который разбирается во фронт-энде и направит нас на истинный путь. Мы наняли фрилансера, который и предложил нам использовать React. Он не очень много поработал у нас, но успел сделать пару контроллов, чтобы показать, что к чему, и ощущения оказались двоякими. Мне очень понравился React с момента прохождения туториала на официальном сайте, но он понравился не всем. К тому же, хардкорные фронтэндщики любят javascript, но в статически типизированном мире нашей разработки javascript популярностью не пользуется (это если мягко сказать), поэтому все эти webpack'и и grunt'ы, которые нам предлагалось использовать, только пугали нас. В итоге было решено сделать несколько прототипов сложного UI'а, используя разные фреймворки для того, чтобы решить, с каким именно нам нужно иметь дело. Сторонники каждого из фреймворков, из которых мы выбирали, должны были сделать прототип одного и того же контролла, чтобы мы могли сравнить код. Мы сравнивали Angular, React и Knockout. Последний не прошёл даже стадию прототипа, и я даже не помню уже, по какой именно причине. Однако между сторонниками Angular'а и React'а в компании развернулась настоящая гражданская война!
Шутка :) На самом деле у каждого фреймворка было по одному стороннику, всем остальным не нравился ни тот, ни другой. Все мялись и не могли ничего решить. В Angular'е всех раздражала его сложность, а в React'е — стрёмный синтаксис, отсутствие поддержки которого в Visual Studio на тот момент было действительно очень неприятным фактом.
К счастью для нас, нам на помощь пришёл наш начальник (один из владельцев компании), который конечно уже давно не программирует, но держит руку на пульсе. После того, как стало ясно, что прототипы никакого эффекта не дали, и разработка тратит время непонятно на что (в тот момент мы планировали сделать ещё один прототип на много большего размера, чтобы было больше кода для сравнения!), принимать решение пришлось ему. Сейчас, вспоминая, почему его выбор тогда всё-таки пал на React, Саша agornik Горник рассказал мне следующее (я привожу его слова не для холивара, это просто мнение. Орфография, разумеется, сохранена, хотя кое-что я всё-таки поправил):
Было несколько прототипов: реакт, ангуляр и еще что-то вроде. Я посмотрел. Ангуляр не понравился, реакт понравился.
Но [кое-кто] кричал громче всех, а все остальные были как овощи. Пришлось читать и смотреть.
Я увидел что реакт — в продакшене на куче крутых сайтов. FB, Yahoo, WhatsApp и еще что-то там. Явно уже огромный адопшн идет и есть будущее.
А на ангуляре — [ничего хорошего]. Посмотрел на будещее. Увидел что всё что мне не понравилось в прототипе ангуляра хотят в 2.0 усилить.
Я понял что react — это штука для жизни сделанная решаюшая конкретную проблему. А ангуляр — это бородатые теоретики из гугла из мозга придумывают всякие концепции. Как было с GWT или как он там.
Ну и понял что надо волевым решением встать на сторону овощей, иначе победят кричащие, но неправые. Перед тем как это сделать я накидал в канал 33 миллиона пруфов и ссылок, заручился поддержкой [нашего главного архитектора] и постарался сделать так, чтобы никто не забатхертил.
А еще я вспомнил какой был адски важный аргумент. Для реакта был красивый способ делать поэтапно и вкрячивать в существующие страницы, а ангуляр требовал переделывать их целиком, и это тоже корреклирует с [его плохой] архитектурой.
Потом я еще прочитал что на реакте в теории можно UI даже не для веба делать. И всякий там серверный js / react и куда всё это идет. И кароче ваще ни одного аргумента не оставалось не брать.
Я понял что поддержку для студии впилят очень быстро. В итоге всё ровно так и вышло. Я конечно адски доволен этим решением)

Что же получилось?


Пришло время раскрыть карты и показать, как мы теперь варим UI. Конечно же, фронт-эндщики сейчас начнут смеяться, но для нас этот код — настоящая победа, мы им очень довольны :)
Для примера буду использовать страницу создания дополнительных полей. Краткая бизнес-справка: у некоторых сущностей, таких как Потребители, Заказы, Покупки и Продукты, могут быть какие-то связанные данные, специфичные для клиента. Для того, чтобы такие данные хранить, мы используем классическую Entity–attribute–value model. Изначально дополнительные поля для каждого клиента заводили прямо в бд (для того, чтобы сэкономить время разработки), но наконец время нашлось и для UI.
Вот, как выглядит страница добавления дополнительного поля в проекте:
Добавление дополнительного поля типа Перечисление


Добавление дополнительного поля типа Строка


А вот, как выглядит код этой страницы на React'е:
Компонент страницы добавления/редактирования дополнительных полей
/// <reference path="../../references.d.ts"/>

module DirectCrm
{
        export interface SaveCustomFieldKindComponentProps extends Model<CustomFieldKindValueBackendViewModel>
        {

        }

        interface SaveCustomFieldKindComponentState
        {
                model?: CustomFieldKindValueBackendViewModel;
                validationContext: IValidationContext<CustomFieldKindValueBackendViewModel>;
        }

        export class SaveCustomFieldKindComponent extends React.Component<SaveCustomFieldKindComponentProps, SaveCustomFieldKindComponentState>
        {
                private _componentsMap: ComponentsMap<CustomFieldKindConstantComponentDataBase, CustomFieldKindTypedComponentProps>;

                constructor(props: SaveCustomFieldKindComponentProps)
                {
                        super(props);

                        this.state = {
                                model: props.model,
                                validationContext: createTypedValidationContext<CustomFieldKindValueBackendViewModel>(props.validationSummary)
                        };
                        this._componentsMap = ComponentsMap.initialize(this.state.model.componentsMap);
                }

                _setModel = (model: CustomFieldKindValueBackendViewModel) =>
                {
                        this.setState({
                                model: model
                        });
                }
                
                _handleFieldTypeChange = (newFieldType: string) =>
                {
                        var clone = _.clone(this.state.model);

                        clone.fieldType = newFieldType;
                        clone.typedViewModel = {
                                type: newFieldType,
                                $type: this._componentsMap[newFieldType].viewModelType
                        };

                        this._setModel(clone);
                }

                _getColumnPrefixOrEmptyString = (entityType: string) =>
                {
                        var entityTypeDto = _.find(this.props.model.entityTypes, et => et.systemName === entityType);
                        return entityTypeDto && entityTypeDto.prefix || "";
                }

                _hanleEntityTypeChange = (newEntityType: string) =>
                {
                        var clone = _.clone(this.state.model);

                        clone.entityType = newEntityType;
                        var columnPrefix = this._getColumnPrefixOrEmptyString(newEntityType);
                        clone.columnName = `${columnPrefix}${this.state.model.systemName || ""}`;

                        this._setModel(clone);
                }

                _handleSystemNameChange = (newSystemName: string) =>
                {
                        var clone = _.clone(this.state.model);

                        clone.systemName = newSystemName;
                        var columnPrefix = this._getColumnPrefixOrEmptyString(this.state.model.entityType);
                        clone.columnName = `${columnPrefix}${newSystemName || ""}`;

                        this._setModel(clone);
                }

                _renderComponent = () =>
                {
                        var entityTypeSelectOptions =
                                this.state.model.entityTypes.map(et =>
                                {
                                        return { Text: et.name, Value: et.systemName }
                                });

                        var fieldTypeSelectOptions = 
                                Object.keys(this._componentsMap).
                                map(key =>
                                {
                                        return {
                                                Text: this._componentsMap[key].name,
                                                Value: key
                                        };
                                });

                        var componentInfo = this._componentsMap[this.state.model.fieldType];
                        var TypedComponent = componentInfo.component;

                        return (
                                <div>
                                        <div className="row form_horizontal">
                                                <FormGroup 
                                                        label="Для сущности"
                                                        validationMessage={this.state.validationContext.getValidationMessageFor(m => m.entityType)}>
                                                        <div className="form-control">
                                                                <Select  
                                                                        value={this.state.model.entityType} 
                                                                        options={entityTypeSelectOptions}
                                                                        width="normal"
                                                                        placeholder="тип сущности"
                                                                        onChange={this._hanleEntityTypeChange} />
                                                        </div>
                                                </FormGroup>      

                                                <DataGroup label="Имя колонки" value={this.state.model.columnName} />

                                                <FormGroup 
                                                        label="Имя"
                                                        validationMessage={this.state.validationContext.getValidationMessageFor(m => m.name)}>
                                                        <Textbox 
                                                                value={this.state.model.name} 
                                                                width="normal"
                                                                onChange={getPropertySetter(
                                                                        this.state.model, 
                                                                        this._setModel,
                                                                        viewModel => viewModel.name)} />
                                                </FormGroup>

                                                <FormGroup 
                                                        label="Системное имя"
                                                        validationMessage={this.state.validationContext.getValidationMessageFor(m => m.systemName)}>
                                                        <Textbox 
                                                                value={this.state.model.systemName} 
                                                                width="normal"
                                                                onChange={this._handleSystemNameChange} />
                                                </FormGroup>

                                                <FormGroup 
                                                        label="Тип поля" 
                                                        validationMessage={this.state.validationContext.getValidationMessageFor(m => m.fieldType)}>
                                                        <div className="form-control">
                                                                <Select  
                                                                        value={this.state.model.fieldType} 
                                                                        options={fieldTypeSelectOptions}
                                                                        width="normal"
                                                                        placeholder="тип поля"
                                                                        onChange={this._handleFieldTypeChange} />
                                                        </div>
                                                </FormGroup>
                                                
                                                <TypedComponent 
                                                        validationContext={this.state.validationContext.getValidationContextFor(m => m.typedViewModel)}
                                                        onChange={getPropertySetter(
                                                                        this.state.model, 
                                                                        this._setModel,
                                                                        viewModel => viewModel.typedViewModel)}
                                                        value={this.state.model.typedViewModel}
                                                        constantComponentData={componentInfo.constantComponentData} />

                                                <FormGroup>
                                                        <Checkbox 
                                                                checked={this.state.model.isMultiple}
                                                                label="Можно много значений в одном поле через запятую"
                                                                onChange={getPropertySetter(
                                                                        this.state.model, 
                                                                        this._setModel,
                                                                        viewModel => viewModel.isMultiple)}
                                                                disabled={false} />
                                                </FormGroup>
                                                
                                                {this._renderShouldBeExportedCheckbox()}
                                        </div>
                                </div>);
                }

                _getViewModelValue = () =>
                {
                        var clone = _.clone(this.state.model);

                        clone.componentsMap = null;
                        clone.entityTypes = null;

                        return clone;
                }

                render() {
            return (
                                <div>
                                        <fieldset>
                        {this._renderComponent() }
                                        </fieldset>
                                        <HiddenInputJsonSerializer model={this._getViewModelValue()} name={this.props.modelName} />
                                </div>);
        }

                _renderShouldBeExportedCheckbox = () =>
                {
                        if (this.state.model.entityType !== "HistoricalCustomer")
                                return null;

                        return (
                                <FormGroup
                                        validationMessage={this.state.validationContext.getValidationMessageFor(m => m.shouldBeExported)}>
                                        <Checkbox 
                                                checked={this.state.model.shouldBeExported}
                                                label="Выгружать в стандартном экспорте"
                                                onChange={getPropertySetter(
                                                        this.state.model, 
                                                        this._setModel,
                                                        viewModel => viewModel.shouldBeExported)}
                                                disabled={false} />
                                </FormGroup>);
                }
        }
}


TypeScript


«Что это было?» — можете спросить вы, если ожидали увидеть javascript. Это tsx — вариант React'ового jsx'а под TypeScript. Наш UI полностью статически типизирован, никаких «магических строк». Согласитесь, этого можно было ожидать от таких хардкорных бэкэндщиков, как мы :)
Тут нужно сказать несколько слов. У меня нет цели поднимать холивар на тему статически- и динамически-типизированных языков. Просто так сложилось, что у нас в компании никто не любит динамические языки. Мы считаем, что на них нельзя очень сложно написать большой поддерживаемый проект, который рефакторится годами. Ну и просто писать сложно, потому что IntelliSense не работает :) Такое вот у нас убеждение. Можно поспорить, что можно всё покрыть тестами, и тогда это будет возможно и с динамически типизированным языком, но спорить на эту тему мы не будем.
Формат tsx поддерживается студией и новым R#, что является ещё одним очень важным моментом. А ведь год назад в студии (не то что в R#) не было поддержки даже jsx'а, и для разработки на js приходилось иметь ещё один редактор кода (мы использовали Sublime и Atom). В следствие этого половины файлов не хватало в студийном Solution'е, что только добавляло батхёртов. Но не будем об этом, ведь счастье уже наступило.
Нужно заметить, что даже typescript в чистом виде не даёт тот уровень статической типизации, который хотелось бы. Например, если мы хотим установить в модели какое-то свойство (фактически сбиндить UI-контролл на какое-то свойство модели), мы можем написать callback-функцию для каждого такого свойства, что долго, и можем использовать один callback, принимающий имя свойства, что ни разу не статически типизировано. Конкретно эта проблема у нас решена примерно таким кодом (вы можете видеть примеры использования getPropertySetter выше):
/// <reference path="../../libraries/underscore.d.ts"/>

function getPropertySetter<TViewModel, TProperty>(
        viewModel: TViewModel,
        viewModelSetter: {(viewModel: TViewModel): void},
        propertyExpression: {(viewModel: TViewModel): TProperty}): {(newPropertyValue: TProperty): void}
{
        return (newPropertyValue: TProperty) =>
        {
                var viewModelClone = _.clone(viewModel);
                var propertyName = getPropertyNameByPropertyProvider(propertyExpression);
                viewModelClone[propertyName] = newPropertyValue;
                viewModelSetter(viewModelClone);
        };
}

function getPropertyName<TObject>(obj: TObject, expression: {(obj: TObject): any}): string
{
        return getPropertyNameByPropertyProvider(expression);
}

function getPropertyNameByPropertyProvider(propertyProvider: Function): string
{
        return /\.([^\.;]+);?\s*\}$/.exec(propertyProvider.toString())[1];
}

Нет никаких сомнений в том, что реализация getPropertyNameByPropertyProvider очень-очень стрёмная (другого слова даже не подберешь). Но другого выбора typescript пока не предоставляет. ExpressionTree и nameof в нём нет, а положительные стороны getPropertySetter перевешивают отрицательные стороны такой реализации. В конце концов, что с ней может случиться? Она может начать тормозить в какой-то момент, и можно будет приписать туда какое-нибудь кэширование, а может к тому времени и какой-нибудь nameof в typescript сделают.
Благодаря такому хаку у нас, например, работает переименование по всему коду и не надо заботиться о том, что что-то где-то развалилось.
В остальном всё работает просто волшебно. Не указал какой-нибудь обязательный prop для компонента? Ошибка компиляции. Передал prop неправильного типа в компонент? Ошибка компиляции. Никаких дурацких PropTypes с их предупреждениями в рантайме. Единственная проблема тут в том, что backend у нас всё-таки на C#, а не на typescript, поэтому каждую модельку, используемую на клиенте, нужно описывать дважды: на сервере и на клиенте. Однако решение этой проблемы существует: мы сами написали прототип генератора типов для typescript из типов на .NET после того, как попробовали opensource'ные решения, которые нас не удовлетворили, но потом прочитали эту статью. Выглядит так, что нужно только применить эту утилиту как-нибудь и посмотреть, как она себя ведёт в боевых условиях. Судя по всему всё уже хорошо.

Отрисовка компонентов


Расскажу более подробно, как мы инициализируем компоненты при открытии страницы и как они взаимодействуют с серверным кодом. Сразу предупрежу, что каплинг довольно высокий, но что поделать.
Для каждого компонента на сервере есть вью-моделька, на которую это компонент сбиндится при POST-запросе. Обычно та же самая вью-моделька используется и для того, чтобы изначально инициализировать компонент. Вот, например, код (C#), который инициализирует вью-модельку страницы дополнительных полей, показанную выше:
Код инициализации вью-модели на сервере
public void PrepareForViewing(MvcModelContext mvcModelContext)
{
	ComponentsMap = ModelApplicationHostController
		.Instance
		.Get<ReactComponentViewModelConfiguration>()
		.GetNamedObjectRelatedComponentsMapFor<CustomFieldKindTypedViewModelBase, CustomFieldType>(
			customFieldViewModel => customFieldViewModel.PrepareForViewing(mvcModelContext));

	EntityTypes = ModelApplicationHostController.NamedObjects
		.GetAll<CustomFieldKindEntityType>()
		.Select(
			type => new EntityTypeDto
			{
				Name = type.Name,
				SystemName = type.SystemName,
				Prefix = type.ColumnPrefix
			})
		.ToArray();

	if (ModelApplicationHostController.NamedObjects.Get<DirectCrmFeatureComponent>().Sku.IsEnabled())
	{
		EntityTypes =
			EntityTypes.Where(
				et => et.SystemName != ModelApplicationHostController.NamedObjects
											.Get<CustomFieldKindEntityTypeComponent>().Purchase.SystemName)
				.ToArray();
	}
	else
	{
		EntityTypes =
			EntityTypes.Where(
				et => et.SystemName != ModelApplicationHostController.NamedObjects
											.Get<CustomFieldKindEntityTypeComponent>().Sku.SystemName)
				.ToArray();
	}

	if (FieldType.IsNullOrEmpty())
	{
		TypedViewModel = new StringCustomFieldKindTypedViewModel();
		FieldType = TypedViewModel.Type;
	}
}


Тут инициализируются некоторые свойства и коллекции, которые будут использоваться для заполнения списков.
Чтобы, используя данные этой вью-модели, нарисовать какой-то компонент, написан Extension-метод HtmlHelper. Фактически, в любом месте, где нам нужно отрендерить компонент, мы используем код:
@Html.ReactJsFor("DirectCrm.SaveCustomFieldKindComponent", m => m.Value)

Первым параметром принимается имя компонента, вторым — PropertyExpression — путь во вью-модели страницы, где находятся данные для данного компонента. Вот код этого метода:
public static IHtmlString ReactJsFor<TModel, TProperty>(
	this HtmlHelper<TModel> htmlHelper,
	string componentName,
	Expression<Func<TModel, TProperty>> expression,
	object initializeObject = null)
{
	var validationData = htmlHelper.JsonValidationMessagesFor(expression);
	var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
	var modelData = JsonConvert.SerializeObject(
		metadata.Model,
		new JsonSerializerSettings
		{
			TypeNameHandling = TypeNameHandling.Auto,
			TypeNameAssemblyFormat = FormatterAssemblyStyle.Full,
			Converters =
			{
				new StringEnumConverter()
			}
		});
	var initializeData = JsonConvert.SerializeObject(initializeObject);

	return new HtmlString(string.Format(
		"<div data-react-component='{0}' data-react-model-name='{1}' data-react-model='{2}' " +
			"data-react-validation-summary='{3}' data-react-initialize='{4}'></div>",
		HttpUtility.HtmlEncode(componentName),
		HttpUtility.HtmlEncode(htmlHelper.NameFor(expression)),
		HttpUtility.HtmlEncode(modelData),
		HttpUtility.HtmlEncode(validationData),
		HttpUtility.HtmlEncode(initializeData)));
}

Фактически, мы просто рендерим div, у которого в атрибутах находятся данные, необходимые для рендеринга компонента: название компонента, путь в более глобальной модели, данные, которыми будет проинициализирован компонент, серверные валидационные сообщения, а так же какие-либо дополнительные данные для инициализации. Далее при отрисовке страницы за счёт нехитрого в этот div будет срендерен компонент:
function initializeReact(context) {
	$('div[data-react-component]', context).each(function () {
		var that = this;

		var data = $(that).data();
		var component = eval(data.reactComponent);

		if (data.reactInitialize == null) {
			data.reactInitialize = {};
		}

		var props = $.extend({
			model: data.reactModel,
			validationSummary: data.reactValidationSummary,
			modelName: data.reactModelName
		}, data.reactInitialize);

		React.render(
			React.createElement(component, props),
			that
		);
	});
}

Таким образом рендерятся основные компоненты, которые хранят основное состояние страницы — то есть в большинстве случаев именно у этих компонентов вообще есть state. Вложенные же в них компоненты обычно либо не имеют состояния вообще, либо их состояние не является важным в рамках страницы (как например флаг открытости/закрытости выпадающего меню в select'е).

Binding


Прекрасно, мы нарисовали компонент, но как же данные попадут обратно на сервер?
Всё довольно просто. По крайней мере в первом приближении. Большинство страниц достаточно просты и используют обычный пост формы. У контроллов в компонентах нет имён, и биндинг происходит за счёт того, что при любом изменении состояния основного компонента (а он фактически хранит состояние всей страницы, как я говорил выше), перерендеривается специальный hidden input, содержащий текущее состояние модели, сериализованное в json. Для того, чтобы этот json биндился на наше ASP.NET приложение, был написан специальный ModelBinder.
Начнём с hidden input'а. Каждый компонент страницы содержит в себе следующий компонент:
<HiddenInputJsonSerializer model={this._getViewModelValue() } name={this.props.modelName} />

Его код довольно прост:
class HiddenInputJsonSerializer extends React.Component<{ model: any, name: string }, {}> {
	render() {
	    var json = JSON.stringify(this.props.model);
		var name = this.props.name;

		return (
			<input type="hidden" value={json} name={name} />
		);
	}
}

При посте формы мы фактически постим одно значение — огромный json с именем, которое оказалось в this.props.modelName — а это то самое имя, которое мы передали в data-react-model-name при рендеринге (см. выше), то есть текстовый путь в некоторой большой вью-модели до нашей вью-модельки, которая приедет json'ом.
Для того, чтобы этот json сбиндился на вью-модель в приложении, используется следующий код. Для начала, свойства вью-моделей, которые мы хотим получать из json'а, должны быть помечены специальным JsonBindedAttribute. Ниже представлен код родительской вью-модели, в которую вложена вью-модель, которая будет биндиться из json:
public class CustomFieldKindCreatePageViewModel : AdministrationSiteMasterViewModel
{
	public CustomFieldKindCreatePageViewModel()
	{
		Value = new CustomFieldKindValueViewModel();
	}

	[JsonBinded]
	public CustomFieldKindValueViewModel Value { get; set; }

	/// другие свойства и методы родительской вью-модели
}

Теперь нужно, чтобы что-то воспользовалось этой информацией и пыталось заполнить свойство CustomFieldKindCreatePageViewModel.Value из строки. Это что-то — ModelBinder. Код довольно логичен: если свойство помечено JsonBindedAttribute — найти в данных формы значение с соответствующим именем и десериализовать его, как CustomFieldKindValueViewModel (в данном случае). Вот его код:
Код биндера, который и десериализует json
public class MindboxDefaultModelBinder : DefaultModelBinder
{
	private object DeserializeJson(
		string json,
		Type type, 
		string fieldNamePrefix,
		ModelBindingContext bindingContext,
		ControllerContext controllerContext)
	{
		var settings = new JsonSerializerSettings
		{
			TypeNameHandling = TypeNameHandling.Auto,
			MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead,
			Converters = new JsonConverter[]
			{
				new ReactComponentPolimorphicViewModelConverter(),
				new FormBindedConverter(controllerContext, bindingContext, fieldNamePrefix)
			}
		};

		return JsonConvert.DeserializeObject(json, type, settings);
	}

	protected override void BindProperty(
		ControllerContext controllerContext,
		ModelBindingContext bindingContext,
		PropertyDescriptor propertyDescriptor)
	{
		if (!propertyDescriptor.Attributes.OfType<JsonBindedAttribute>().Any())
		{
			base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
		}
	}

	public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
	{
		var result = base.BindModel(controllerContext, bindingContext);

		// ...
		// код, не имеющий отношения к делу
		// ...

		if (result != null)
		{
			FillJsonBindedProperties(controllerContext, bindingContext, result);
		}

		return result;
	}

	private static string BuildFormVariableFullName(string modelName, string formVariableName)
	{
		return modelName.IsNullOrEmpty() ? formVariableName : string.Format("{0}.{1}", modelName, formVariableName);
	}

	private void FillJsonBindedProperties(
		ControllerContext controllerContext,
		ModelBindingContext bindingContext,
		object result)
	{
		var jsonBindedProperties = result.GetType().GetProperties()
				.Where(pi => pi.HasCustomAttribute<JsonBindedAttribute>())
				.ToArray();

		foreach (var propertyInfo in jsonBindedProperties)
		{
			var formFieldFullName = BuildFormVariableFullName(
				bindingContext.FallbackToEmptyPrefix ? string.Empty : bindingContext.ModelName,
				propertyInfo.Name);

			if (controllerContext.HttpContext.Request.Params.AllKeys.Contains(formFieldFullName))
			{
				var json = controllerContext.HttpContext.Request.Params[formFieldFullName];
				if (!json.IsNullOrEmpty())
				{
					var convertedObject = DeserializeJson(
						json, propertyInfo.PropertyType, formFieldFullName, bindingContext, controllerContext);

					propertyInfo.SetValue(result, convertedObject);
				}
			}
			else
			{
				throw new InvalidOperationException(
					string.Format(
						"Не сработал биндер для property {0} из типа {1}. В 99.9% случаев свидетельствует об ошибке в js.",
						formFieldFullName,
						result.GetType().AssemblyQualifiedName));
			}
		}
	}
}


Заметьте, что если мы ожидали, что свойство будет биндиться из json, и при этом json не пришёл, мы упадём, так как с 99.9% вероятностью произошла какая-то ошибка на клиенте, из-за чего компонент даже не был отрендерен. Либо мы ошиблись при просовывании имени в компонент, но такая ошибка обычно отлавливается на этапе разработки.
К сожалению, невозможно в одночасье переписать всю кодовую базу на новый фреймворк, и довольно большое количество страниц до сих пор используют html, отрисовываемый на сервере, и react-компоненты одновременно. Бывают ситуации, когда какой-то кусочек страницы отрисован react'ом, и внутри этого кусочка часть отрисована на сервере, а внутри этого кусочка часть снова отрисовывается react'ом. Такая сложность возникла, например, на странице создания триггера. Я приводил её выше, но на всякий случай приведу её скриншот ещё раз тут:
Страница создания триггера


Вся страница является одним большим компонентом, однако первая стрелка указывает на компонент «Фильтр», который сделан на чистом js ещё несколько лет назад, и переписать его на react — задача, оцениваемая в месяц. При этом js, который отрисовывает фильтр, на самом деле отрисовывает html с сервера, на js написана только общая логика работы контрола. Однако, так как большой фильтр состоит из набора фильтров поменьше, и некоторые из фильтров обладают довольно нетривиальным UI-ем, нужно иметь возможность делать такие фильтры, используя react. Вторая стрелка указывает на такой фильтр по сущности «Шаблон действия», он сделан, как react'овый компонент.
Каким образом происходит биндинг такой структуры? Для того, чтобы это работало, у каждого input'а внутри фильтра должен быть правильным образом заполнен name, префикс которго приходится протаскивать через внешний компонент, написанный на react. Один из таких input'ов может быть нашем hidden input'ом, хранящим состояние какого-либо сложного внутреннего фильтра. Однако все значения обычных контроллов, пришедшие в POST-запросе, были бы просто проигнорированы, так как вью-модель, содержащая состояние страницы, помечена JsonBindedAttribute, а значит, что она и все вложенные в неё объекты должны быть просто сериализованы из json. Для того, чтобы заполнить часть такой вью-модели из обычных данных формы, её внутреннее свойство должно быть помечено FormBindedAttribute, а при десериализации из json нужно использовать FormBindedConverter, код которого представлен ниже:
Код FormBindedConverter
public class FormBindedConverter : JsonConverter
{
	private readonly ControllerContext controllerContext;

	private readonly ModelBindingContext parentBindingContext;

	private readonly string formNamePrefix;

	private Type currentType = null;

	private static readonly Type[] primitiveTypes = new[]
	{
		typeof(int),
		typeof(bool),
		typeof(long),
		typeof(decimal),
		typeof(string)
	};

	public FormBindedConverter(
		ControllerContext controllerContext,
		ModelBindingContext parentBindingContext,
		string formNamePrefix)
	{
		this.controllerContext = controllerContext;
		this.parentBindingContext = parentBindingContext;
		this.formNamePrefix = formNamePrefix;
	}

	public override bool CanConvert(Type objectType)
	{
		return currentType != objectType && !primitiveTypes.Contains(objectType);
	}

	public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
	{
		var currentJsonPath = reader.Path;

		currentType = objectType;
		var result = serializer.Deserialize(reader, objectType);
		currentType = null;

		if (result == null)
			return null;

		var resultType = result.GetType();
		var formBindedProperties = resultType.GetProperties().Where(p => p.HasCustomAttribute<FormBindedAttribute>());
		foreach (var formBindedProperty in formBindedProperties)
		{
			var formBindedPropertyName = formBindedProperty.Name;
			var formBindedPropertyFullPath = $"{formNamePrefix}.{currentJsonPath}.{formBindedPropertyName}";

			var formBindedPropertyModelBinderAttribute =
				formBindedProperty.PropertyType.TryGetSingleAttribute<ModelBinderAttribute>();

			var effectiveBinder = GetBinder(formBindedPropertyModelBinderAttribute);
			var formBindedObject = effectiveBinder.BindModel(
				controllerContext,
				new ModelBindingContext(parentBindingContext)
				{
					ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(
						() => formBindedProperty.GetValue(result),
						formBindedProperty.PropertyType),
					ModelName = formBindedPropertyFullPath
				});

			formBindedProperty.SetValue(result, formBindedObject);
		}

		return result;
	}

	private static IModelBinder GetBinder(ModelBinderAttribute formBindedPropertyModelBinderAttribute)
	{
		IModelBinder effectiveBinder;
		if (formBindedPropertyModelBinderAttribute == null)
		{
			effectiveBinder = new MindboxDefaultModelBinder();
		}
		else
		{
			effectiveBinder = formBindedPropertyModelBinderAttribute.GetBinder();
		}

		return effectiveBinder;
	}

	public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
	{
		serializer.Serialize(writer, value);
	}
}


Этот конвертер отслеживает цепочку вложенности вью-моделей при десериализации из json, а так же просматривает десериализуемые типы на наличие FormBindedAttribute. Если какое-то свойство помечено таким атрибутом, то мы выясняем, какой binder нужно использовать для получения этого свойства из данных формы, инстанцируем этот binder и просим его заполнить нужное свойство.
Таким образом при связывании достаточно сложной модели мы попадаем в MindboxDefaultModelBinder, из которого попадаем в FormBindedConverter, из которого попадаем в FilterViewModelBinder, из которого снова попадаем в MindboxDefaultModelBinder.

Полиморфные вью-модели


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


Добавление строкового поля


Добавление перечисления


В зависимости от типа поля необходимо отображать разный UI. Такую задачу можно решить, написав switch по типам полей, но мне по душе более полиморфный подход. В итоге, для подобных выпадалок для каждого значения в ней существует некий компонент, который и отрисовывается в случае выбора соответствующего значения. Вот код подобных компонентов:
module DirectCrm {
    export class StringCustomFieldKindComponent extends CustomFieldKindComponentBase {
        render() {
            var stringViewModel = this.props.value as StringCustomerFieldKindTypedBackendViewModel;
            var stringConstantData = this.props.constantComponentData as StringCustomFieldKindConstantComponentData;
            var validationContext = this.props.validationContext as IValidationContext<StringCustomerFieldKindTypedBackendViewModel>;

            return (
                <div>
                    {super.render() }
                <FormGroup
                    label="Ограничение к значению"
                    validationMessage={validationContext.getValidationMessageFor(m => m.validationStrategySystemName) } >
                    <div className="form-control">
                        <Commons.Select
                            value={stringViewModel.validationStrategySystemName}
                            width="normal"
                            onChange={getPropertySetter(
                                stringViewModel,
                                vm => this.props.onChange(vm),
                                m => m.validationStrategySystemName) }
                            options={stringConstantData.validationStrategies}
                            disabled={this.props.disabled}/>
                        </div>
                    </FormGroup>
                    </div>);
        }
    }
}

module DirectCrm {
    export class DefaultCustomFieldKindComponent extends CustomFieldKindComponentBase {
    }
}

module DirectCrm {
    export class CustomFieldKindComponentBase extends React.Component<DirectCrm.CustomFieldKindTypedComponentProps, {}> {
        render() {
            return <FormGroup
                label = "Тип поля"
                validationMessage = { this.props.validationMessageForFieldType } >
					<div className="form-control">
                        <Commons.Select
                            value={this.props.fieldType}
                            options={this.props.fieldTypeSelectOptions}
                            width="normal"
                            placeholder="тип поля"
                            onChange={this.props.handleFieldTypeChange}
                            disabled = {this.props.disabled}/>
                </div>
                {this.renderTooltip() }
                </FormGroup>
        }

        renderTooltip() {
            return <Commons.Tooltip
				additionalClasses="tooltip-icon_help"
				message={this.props.constantComponentData.tooltipMessage }/>
        }
    }
}

Как же в зависимости от выбранного значения типа выбирается нужный компонент для рендеринга?
Это можно увидеть в коде компонента всй страницы, приведу нужный кусочек здесь ещё раз:
_renderComponent = () => {
	var fieldTypeSelectOptions =
		Object.keys(this._componentsMap).
			map(key => {
				return {
					Text: this._componentsMap[key].name,
					Value: key
				};
			});

	var componentInfo = this._componentsMap[this.state.model.fieldType];
	var TypedComponent = componentInfo.component;

	return (
		<div>
			<div className="row form_horizontal">
				<div className="col-group">
					// другие части страницы

					<TypedComponent
						validationContext={this.state.validationContext.getValidationContextFor(m => m.typedViewModel) }
						onChange={getPropertySetter(
							this.state.model,
							this._setModel,
							viewModel => viewModel.typedViewModel) }
						value={this.state.model.typedViewModel}
						fieldType={this.state.model.fieldType}
						validationMessageForFieldType={this.state.validationContext.getValidationMessageFor(m=> m.fieldType) }
						fieldTypeSelectOptions={fieldTypeSelectOptions}
						handleFieldTypeChange={this._handleFieldTypeChange}
						constantComponentData={componentInfo.constantComponentData}
						disabled={!this.state.model.isNew}/>
				</div>

				// другие части страницы
		</div>);
}

Как вы видите из кода, происходит рендеринг некого TypedComponent, который был получен путём некоторых манипуляций с объектом _componentsMap. Этот _componentsMap — просто словарь, где значениям типа (выбранным в выпадалке «тип поля») соответствуют объекты componentInfo, хранящие данные, специфичные для конкретного типизированного компонента: сама фабрика компонента, константные данные (списки, url-ы до каких-то важных этому компоненту сервисов), а так же строковое представление .NET типа, которое будет необходимо для того, чтобы правильно десериализовать данную вью-модель. Структура _componentsMap в json представлена ниже:
Структура ComponentsMap'а
"componentsMap":{  
      "Integer":{  
         "name":"Целочисленный",
         "viewModelType":"Itc.DirectCrm.Web.IntegerCustomFieldKindTypedViewModel, itc.DirectCrm.Web, Version=6.459.0.3741, Culture=neutral, PublicKeyToken=null",
         "componentName":"DirectCrm.DefaultCustomFieldKindComponent",
         "constantComponentData":{  
            "$type":"Itc.DirectCrm.Web.IntegerCustomFieldKindTypedViewModel, itc.DirectCrm.Web, Version=6.459.0.3741, Culture=neutral, PublicKeyToken=null",
            "tooltipMessage":"Пример: 123456",
            "type":"Integer"
         }
      },
      "String":{  
         "name":"Строковый",
         "viewModelType":"Itc.DirectCrm.Web.StringCustomFieldKindTypedViewModel, itc.DirectCrm.Web, Version=6.459.0.3741, Culture=neutral, PublicKeyToken=null",
         "componentName":"DirectCrm.StringCustomFieldKindComponent",
         "constantComponentData":{  
            "$type":"Itc.DirectCrm.Web.StringCustomFieldKindTypedViewModel, itc.DirectCrm.Web, Version=6.459.0.3741, Culture=neutral, PublicKeyToken=null",
            "validationStrategies":[  
               {  
                  "Disabled":false,
                  "Group":null,
                  "Selected":true,
                  "Text":"Без ограничений",
                  "Value":"Default"
               },
               {  
                  "Disabled":false,
                  "Group":null,
                  "Selected":false,
                  "Text":"Буквы латинского алфавита и пробелы",
                  "Value":"IsValidLatinStringWithWhitespaces"
               },
               {  
                  "Disabled":false,
                  "Group":null,
                  "Selected":false,
                  "Text":"Буквы латинского алфавита и цифры",
                  "Value":"IsValidLatinStringWithDigits"
               },
               {  
                  "Disabled":false,
                  "Group":null,
                  "Selected":false,
                  "Text":"Цифры",
                  "Value":"IsValidDigitString"
               }
            ],
            "validationStrategySystemName":"Default",
            "tooltipMessage":"Пример: \"пример\"",
            "type":"String"
         }
      },
      "Enum":{  
         "name":"Перечисление",
         "viewModelType":"Itc.DirectCrm.Web.EnumCustomFieldKindTypedViewModel, itc.DirectCrm.Web, Version=6.459.0.3741, Culture=neutral, PublicKeyToken=null",
         "componentName":"DirectCrm.EnumCustomFieldKindComponent",
         "constantComponentData":{  
            "$type":"Itc.DirectCrm.Web.EnumCustomFieldKindTypedViewModel, itc.DirectCrm.Web, Version=6.459.0.3741, Culture=neutral, PublicKeyToken=null",
            "selectedEnumValues":null,
            "forceCreateEnumValue":false,
            "tooltipMessage":"Пример: Внешний идентификатор - \"ExternalId\", Имя - \"Тест123\"",
            "type":"Enum"
         }
      }
   }


Кто же создаёт данный словарь? Он создаётся серверным кодом на основании специальной конфигурации. Вот код, который создаёт ComponentsMap при инициализации базовой вью-модели на сервере:
public void PrepareForViewing(MvcModelContext mvcModelContext)
{
	ComponentsMap = ModelApplicationHostController
		.Instance
		.Get<ReactComponentViewModelConfiguration>()
		.GetNamedObjectRelatedComponentsMapFor<CustomFieldKindTypedViewModelBase, CustomFieldType>(
			customFieldViewModel => customFieldViewModel.PrepareForViewing(mvcModelContext));

	// ещё какая-то инициализация
}

Для того, чтобы ReactComponentViewModelConfiguration знала, какие вью-модели соответствуют базовой CustomFieldKindTypedViewModelBase, их нужно заранее зарегистрировать. Код регистрации выглядит нехитро:
configuration.RegisterNamedObjectRelatedViewModel<CustomFieldKindTypedViewModelBase, CustomFieldType>(
	() => new StringCustomFieldKindTypedViewModel());
configuration.RegisterNamedObjectRelatedViewModel<CustomFieldKindTypedViewModelBase, CustomFieldType>(
	() => new IntegerCustomFieldKindTypedViewModel());
configuration.RegisterNamedObjectRelatedViewModel<CustomFieldKindTypedViewModelBase, CustomFieldType>(
	() => new EnumCustomFieldKindTypedViewModel());

Далее это свойство вью-модели просто попадает на клиент точно так же, как и все остальные. При этом название компонента на клиенте является частью вью-модели наследницы в C# коде. Как я и говорил, каплинг довольно высокий.

Валидация


В наше приложение попадают данные из множества разных источников:
  • мы сами используем сервисы подрядчиков
  • наши подрядчики используют наши сервисы
  • наш административный сайт является источником данных

Вне зависимости от того, как именно данные попадают в нашу систему, существуют некоторые бизнес-правила доменной модели, консистентность которой нам необходимо поддерживать. Эти бизнес-ограничения находятся внутри самой доменной модели и реализованы одной из разновидностью паттерна Нотификация. Архитектуре нашей валидации можно посвятить отдельную статью, так что я сейчас подробно не буду её описывать. Скажу только то, что так как валидация находится внутри доменной модели, а дублировать код не хочется, необходимо протаскивать валидационные сообщения после их возникновения на клиент. Так же на клиенте необходимо иметь некий фреймворк, позволяющий отображать валидационные сообщения рядом с контроллами, содержащими невалидные данные.
Начнём с клиентской части. Валидационные сообщения приезжают в основной компонент при его отрисовке на сервере в data-react-validation-summary (см. код ReactJsFor выше). Validation summary — это иерархический json, где имени каждого свойства валидируемой вью-модели соответствует валидационная ошибка (если она есть), либо объект, содержащий валидационные ошибки вложенных вью-моделей. Для примера, покажу значение validationSummary для ситуации на скриншоте ниже:
Неудачная попытка сохранения дополнительного поля


Вёртка валидационного сообщения внутри таблицы значений перечислений немного развалилась, но мы видим, что есть некоторые ошибки при сохранении.
Вот как выглядит validation summary для этого случая:
{  
   "typedViewModel":{  
      "selectedEnumValues[0]":{  
         "systemName":[  
            "Идентификатор значения перечисления должен быть короче 250 символов"
         ]
      }
   },
   "name":[  
      "Имя обязательно"
   ]
}

Теперь всё, что нам нужно на клиенте — уметь перемещаться по этому объекту, и отображать валидационные ошибки, если они есть. Для достижения этого используется ValidationContext, которому при создании передается validation summary, и который имеет следующий интерфейс:
interface IValidationContext<TViewModel>
{
	isValid: boolean;
	getValidationMessageFor: { (propertyExpression: {(model: TViewModel):any}): JSX.Element };
	validationMessageExpandedFor: { (propertyExpression: {(model: TViewModel):any}): JSX.Element };
	getValidationContextFor: { <TProperty>(propertyExpression: {(model: TViewModel):TProperty}): IValidationContext<TProperty> };
	getValidationContextForCollection: { <TProperty>(propertyExpression: {(model: TViewModel):TProperty[]}): {(index: number): IValidationContext<TProperty>} }
}

Как видите, он полностью статически типизирован. Оценим это на примере. Вот, как используется этот контекст для отображения валидационного сообщения у поля «Имя»:
<FormGroup
	label="Имя"
	validationMessage={this.state.validationContext.getValidationMessageFor(m => m.name) }>
	<Commons.Textbox
		value={this.state.model.name}
		width="normal"
		onChange={getPropertySetter(
			this.state.model,
			this._setModel,
			viewModel => viewModel.name) } />
</FormGroup>

В этом примере this.state.validationContext имеет тип IValidationContext<CustomFieldKindValueBackendViewModel>, за счёт чего достигается статическая типизация при выборе свойства модели. Причем для достижения такого эффекта даже не используется злополучная getPropertyNameByPropertyProvider, описанная выше, так как на самом деле нужно просто выполнить переданную в getValidationMessageFor функцию над текущим состоянием validation summary и посмотреть на результат.
Теперь вкратце расскажу, как формируется объект validation summary на сервере.
Так как сама валидация происходит в доменной модели, то необходимо как-то связывать валидационные собщения с источниками данных, которые к этим валидационным сообщениям привели. Каждое валидационное сообщение связывается со специальным объектом, называемым ключом валидации, а конкретные ключи валидации связываются с источниками данных для этих ключей. В административном сайте источниками данных являются контроллы на странице, а если говорить с точки зрения серверного кода — свойства вью-моделей. То есть ключу валидации фактически ставится в соответствие путь от корня вью-модели до её свойства какой-либо вложенности. Этот путь в итоге хранится строкой, в которой имена свойств разделяются точками, а для индексации используются квадратные скобки. Всё, что нам нужно — попытаться сохранить, и если это не удалось, собрать валидационные сообщения, хранящие такие пути и валидационные ошибки, и преобразовать подобные пути в формат, отвечающий требованиям validation summary.
Вот как выглядит связывание пути во вью-модели с ключом валидации для поля «Имя» из примера выше:
private void RegisterEndUserInput(
	ISubmodelInputRegistrator<CustomFieldKindValueViewModel> registrator,
	CustomFieldKind customFieldKind)
{
	// ещё код

	registrator.RegisterEndUserInput(
		customFieldKind,
		cfk => cfk.Name,
		this,
		m => m.Name);

	// ещё код
}

Здесь this — как раз вью-модель, содержащая свойство Name, являющееся источником информации, которая попадёт в свойство Name объекта CustomFieldKind customFieldKind. Из объекта и выражения доступа к свойству создаётся ключ валидации, и с ним связывается путь до свойства Name во вью-модели.
Внутри кода сущности CustomFieldKind валидируется наличие имени:
public void Validate(ValidationContext validationContext)
{
	// другие цепочки валидации

	validationContext
		.Validate(this, cfk => cfk.Name)
		.ToHave(() => !Name.IsNullOrEmpty())
		.OrAddError<CustomFieldCustomizationTemplateComponent>(c => c.NameRequired);

	// другие цепочки валидации
}

В момент сохранения сущности в бд мы поймём, что контекст невалиден и не произведём сохранения, ключ валидации, полученный из CustomFieldKind.Name будет помечен как невалидный, и с ним будет связана ошибка валидации, которую мы сможем отобразить на странице.

В заключение


В этой статье я постарался как можно более подробно рассказать, как у нас устроена архитектура работы с UI. В ней есть как очевидные плюсы в виде качественной валидации в доменной модели, статической типизации, так и очевидные минусы, о некоторых из которых я умолчал :)
В любом случае, я надеюсь, что эта статья во-первых заставит вас задуматься о том, чтобы использовать новые UI фреймворки, даже если у вас суровый Enterprise. Не очень важно, что именно использовать. Нам больше нравится ReactJS, но может быть вам подойдёт что-то другое. Во-вторых, надеюсь, что эта статья подстегнёт тех, кто увидел в ней пространство для улучшения, не стесняться и предлагать методы сделать наш код лучше! Очень надеюсь на конструктивную критику и советы от сообщества.
Tags:
Hubs:
+15
Comments 28
Comments Comments 28

Articles

Information

Website
www.mindbox.ru
Registered
Employees
201–500 employees
Location
Россия