Як ми перестали боятися тікетів на UI

Всім привіт.
Минуло вже більше року з тих пір, як ми почали використовувати ReactJS в розробці. Нарешті прийшов момент для того, щоб поділитися тим, наскільки щасливіше стала наша компанія. У статті я збираюся розповісти про причини, які спонукали нас використовувати цю бібліотеку і про те, як ми це робимо.

Навіщо все це
Ми — маленька компанія, наш штат складає близько 50 осіб, 20 з яких розробники. Зараз у нас 4 команди розробки, у кожній з яких сидить по 5 fullstack розробника. Але одне діло називати себе fullstack-розробником, а інша — справді гаразд розбиратися в тонкощах роботи SQL Server'mssql а, 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 ? "ні" : string.Empty) row form_horizontal">
<table class="table table_hover table_control @(Model.OperationWorkflowAllowances.Any() ? String.Empty : "ні")" 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, але може бути вам підійде щось інше. По-друге, сподіваюся, що ця стаття підстьобне тих, хто побачив у ній простір для вдосконалення, не соромитися і пропонувати методи зробити наш код краще! Дуже сподіваюся на конструктивну критику і поради від спільноти.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.