Как написать тикет систему

Всем привет.
Прошло уже больше года с тех пор, как мы начали использовать 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, но может быть вам подойдёт что-то другое. Во-вторых, надеюсь, что эта статья подстегнёт тех, кто увидел в ней пространство для улучшения, не стесняться и предлагать методы сделать наш код лучше! Очень надеюсь на конструктивную критику и советы от сообщества.

Как пользоваться тикет-системой

Что такое Тикет? Как правильно его создать?

Тикет — это задача, которую перед нами ставит клиент. Задача может быть простой или сложной. Если она сложная, то иногда бывает эффективнее разделить ее на несколько, и решать по отдельности. То есть — создать несколько тикетов. Например, при разработке сайта можно разделить задачу на этапы: создание графических макетов, верстка, программирование. Руководствоваться нужно здравой логикой и (важно!) предполагаемым количеством сообщений в обсуждении задачи.

Технически создать тикет просто. Перейдите в раздел Тикеты, нажмите на ссылку «Создать новый тикет» и заполните необходимые поля.

Частые ошибки и хорошая практика при обсуждении

  • Иногда вместо ответа клиент создает новый тикет. Конечно же, в созданном тикете можно и нужно вести переписку!
  • Не следует пользоваться перепиской в тикете, как чатом — писать много коротких малоинформативных сообщений. Это засоряет тикет, в итоге он может стать совсем нечитабельным. Если каждое сообщение — это отдельная мини-задача, то удобно сгруппировать их  в одном сообщении по пунктам: 1), 2) и т.д.
  • Неинформативные заголовки становятся проблемой, если вы захотите найти какую-то информацию в старом тикете через полгода. Озаглавливайте тикет максимально информативно. «Не работает!» — неправильный вариант. «Починить форму обратной связи» — гораздо лучше.
  • При переписке часто приходится ссылаться на какой-то текст. Хотя в мини-редакторе есть возможность процитировать кусок текста, часто бывает удобнее сослаться просто на номер пункта в переписке. Всем понятно, и не загромождает тикет. Можно сослаться на подпункт внутри пункта :)
  • У нас уходит меньше рабочего времени, когда отдельные задачи внутри конкретного тикета четко поставлены. То есть, желательно не просто «пожаловаться на проблему», а попытаться сформулировать, что именно мы должны сделать.
  • Если проблема успешно решена, и тикет переведен в Исполненные, то не пишите в нем сообщения типа «ОК», «Спасибо»! Это возвращает тикет в Работу.

Спасибо, что прочитали эту памятку!

Весь список частых вопросов →

В данный момент у компании EFSOL для поддержки клиентов, а также внутренних сервисов используется платная тикет-система, которую мы получаем по модели SaaS. Со стороны функционала претензий к системе нет. Однако, мы задумались о другом решении для организации тикет-системы из-за экономических факторов:

  1. Количество сотрудников нашей компании постоянно увеличивается и, соответственно, ежемесячные затраты растут.
  2. У нас есть свой хостинг (облачные серверы и техническая поддержка) — нет проблем размещать сервис у себя, а не пользоваться сторонним облачным решением.

Выбор решения для тикет-системы

Поиск тикет-системы производился, используя следующие требования:

  1. Продукт должен быть на базе свободного ПО
  2. Установка должна выполняться на бесплатную ОС
  3. Система должна соответствовать принципам ITSM
  4. Система должна иметь широкие возможности по кастомизации и доработке.
  5. Постановка заявок стандартными способами : Email, телефон, web-интерфейс пользователя.
  6. Должна быть возможность формировать необходимые отчеты:
    • отчет по заявкам: количество, %просрочки, %инцидентов, статистика по клиентам и исполнителям)
    • отчет по оценке заявок
    • отчет по количеству возвращенных заявок

Были обозначены цели, которые планируем достичь с использованием новой тикет-системы:

  • Перевести все взаимодействие внутренних служб ( план «Минимум»)
  • Перевести текущих клиентов и использовать для новых (план «Максимум»).

Из всех решений, которые были рассмотрены, наиболее подходящим показался OTRS.

В таблице 1 ниже приведены наиболее распространенные термины которые используются при работе с системой.

Таблица 1 — Термины, используемые при работе с тикет-системой

Термин Обозначение
1 Заявка (Тикет) Любое обращение в службу технической поддержки.
2 Инцидент Незапланированное прерывание сервиса или снижение качества его работы. Например выход из строя оборудования, медленная работа сервисов.
3 Запрос на обслуживание Запрос от пользователя для предоставления консультации или выполнения плановых действий, например, установка нового ПО или оборудования.
4 Клиент Внешний пользователь системы. Тот, кто формирует заявку.
5 Агент Внутренний пользователь системы. Тот, кто заявку обрабатывает
6 Очередь Место расположения заявки, сущность, которая позволяет разделить общий массив заявок.
7 Каталог услуг База данных или документ, который содержит перечень услуг, предоставляемых клиенту.
8 SLA Соглашение об уровне услуг. Соглашение, в котором регламентированы сроки решения заявок в зависимости от услуги, типа заявки и ее приоритета.
9 Блокировка заявки Агент, когда принимает заявку к выполнению, становится ее владельцем и, таким образом, блокирует заявку. Заблокированная заявка не видна другим агентам очереди и они не могут ее закрыть.

Обзор OTRS

Рассмотрим функционал системы, который можно получить сразу после установки и базовой настройки:

  1. Заведение клиентов, группировка их по компаниям.
  2. Заведение агентов , группировка их по группам — это могут быть группы или линии поддержки.
  3. Создание каталога услуг.
  4. Создание очередей. К разным очередям можно предоставить разные доступы и разные оповещения.
  5. Регистрация заявок доступна 3-мя способами:
    • По почте. Письмо с почты автоматически преобразуется в заявку в определенной очереди. Можно настроить прием заявок в разные очереди с разных ящиков.

      Интерфейс настройки почты для приема заявок

      Рисунок 1 — Интерфейс настройки почты для приема заявок

    • Через Web-интерфейс. Клиент оставляет заявку через Web-сайт. При постановке заявки через Web-интерфейс, есть возможность выбрать услугу, а также срочность заявки.

      Интерфейс пользователя OTRS

      Рисунок 2 — Интерфейс пользователя OTRS

    • По телефону. Агент ставит заявку, которую он принял по телефону от клиента.
  6. Обо всех движениях заявки приходят оповещения на почту клиента.
  7. Создание SLA. Есть возможность настроить граничное время решения заявки в зависимости от сервиса и приоритета заявки. Стоит отметить, что делается это не очень просто. Сначала создаются SLA, в которых прописано граничное время реакции, эскалации и решения в привязке к сервису.

    Настройка SLA

    Рисунок 3 — Настройка SLA

    Далее необходимо сопоставить приоритет заявки с ее типом и SLA. В таблице ниже настраивается матрица какой ID SLA будет выбран при определенном ID типа заявки и приоритете.

    Сопоставление SLA, типа заявки и приоритета

    Рисунок 4 — Сопоставление SLA, типа заявки и приоритета

  8. Присутствуют базовые отчеты:
    • Перечень открытых заявок.
    • Перечень закрытых заявок.
    • Перечень заявок созданных за последний месяц.
    • Новые заявки.
    • Обзор изменения статусов заявок за месяц.
    • Обзор всех заявок в системе.
  9. Обработка заявки происходит достаточно просто. Входящая заявка попадает в определенную очередь. Заявку видят все операторы данной очереди, если оператор принимает заявку — она им блокируется и другие операторы уже не смогут с ней работать.

    Интерфейс обработки заявки оператором

    Рисунок 5 — Интерфейс обработки заявки оператором

    Оператор проводит классификацию заявки указывая приоритет и сервис. Далее может закрыть ее сам или же передать другому оператору или в другую очередь.

    Интерфейс классификации заявки

    Рисунок 6 — Интерфейс классификации заявки

Настройка системы OTRS

Для реализации первого этапа установили систему и начали ее настройку.

Базовая настройка

После того как система была установлена, ее нужно было настроить.

  • Создан и подключен к системе почтовый ящик для приема писем с заявками.
  • Создано и подключено DNS имя для использования Web-интерфейса.
  • Заведены сотрудники компании «агенты» и сгруппированы по отделам. Клиентов не создавали — так как система будет пока служить для постановки заявок между сотрудниками.
  • Созданы 2 очереди техподдержки: 1-я и 2-я линия поддержки.
  • Созданы 2 очереди техподдержки: 1-я и 2-я линия поддержки.
  • Вставили логотип и русифицировали интерфейс и уведомления.

Страница входа в OTRS

Рисунок 7 — Страница входа в OTRS

В результате получился инструмент, позволяющий использовать такой функционал:

  • Отправить заявку письмом или же через web-интерфейс.
  • Заявка регистрируется, ей присваивается номер, передается между исполнителями, закрывается.
  • Автору заявки приходят уведомления о состоянии заявки, также автор может прикрепить свой комментарий через личный кабинет или ответив на письмо.

Базовая кастомизация

Для соответствия системы потребностям компании выполнили небольшую кастомизацию:

Настроен SLA. Регламентное время решения заявки вычисляется в зависимости от типа заявки (запрос на обслуживание или инцидент), приоритета и сервиса. Настроено рабочее время. Также выполнен расчет приоритета в зависимости от срочности и критичности заявки. Изначально в заявках есть только параметр приоритета.

Настроено ограничение на прием заявок. Настроили прием заявок только с почты клиентов или агентов. С адресов, не зарегистрированных в системе, заявки не принимаются, в ответ отправляется уведомление о незарегистрированном адресе.

Реализован механизм оценки заявок. При закрытии заявки исполнителем, автор получает уведомление с просьбой принять выполнение заявки и поставить оценку. Если у автора есть замечания, он отвечает на данное письмо и заявка возобновляется в работу, а его ответ крепиться комментарием к заявке.

Уведомление на почту о закрытой заявке

Рисунок 8 — Уведомление на почту о закрытой заявке

Результат оценки заявки

Рисунок 9 — Результат оценки заявки

Реализован механизм подсчета возвращенных заявок. Количество возвращенных заявок и количество раз, которое они были возвращены является очень важным показателем качества работы ИТ-службы, поэтому в форму заявки был добавлен счетчик возобновления заявок.

Отчеты

Для оценки качества работы ИТ-подразделения пока решили использовать 3 отчета:

Отчет по заявкам. Показывает количество заявок за определенный период, в том числе количество и процент просроченных заявок — наиболее важный параметр. Также предоставляется информация по количеству и процентному соотношению типов заявок и состояний.

Пример Отчета по заявкам

Рисунок 10 — Пример Отчета по заявкам

Отчет по оценке заявок. Показывает среднюю оценку по всем заявкам за выбранный период и по каждому типу в отдельности.

Пример Отчета по оценке заявок

Рисунок 11 — Пример Отчета по оценке заявок

Отчет «Возвращенные заявки». Показывает количество возвращенных заявок в абсолютном и процентном соотношении и показывает сколько раз была возвращена конкретно каждая заявка.

Пример Отчета «Возвращенные заявки»

Рисунок 12 — Пример Отчета «Возвращенные заявки»

Во всех отчетах настроен интервал диапазона дат — текущее число. Отчеты можно формировать по отдельному клиенту, исполнителю или очереди.

Интерфейс для формирования отчетов на примере Отчета по заявкам

Рисунок 13 — Интерфейс для формирования отчетов на примере Отчета по заявкам

Планы по дальнейшему развитию

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

В будущем планируется реализовать следующий функционал:

  1. Создать веб-форму для отчетов. В данный момент отчеты выгружаются сразу в файл. Было бы удобнее чтобы формировалась форма в браузере, а уже потом, если необходима выгрузка, выгружать ее в необходимый формат.
  2. Сделать более презентабельную форму формирования отчетов.
  3. Реализовать механизм «Замораживания заявок». Сделать возможность, когда исполнитель смог бы приостановить выполнение заявки, указав причину. При этом SLA замораживается. Актуально для заявок, к решению которых невозможно приступить в ближайшее время, например, автор заявки просит подключить ему компьютер, который будет куплен только через 3 дня.
  4. Создание красивого клиентского интерфейса.
  5. Механизм согласования заявок. Необходимо создать механизм, когда определенный тип заявок не может быть выполнен исполнителем без согласования ответственного сотрудника. Очень актуально для заявок связанных с безопасностью и предоставлению доступа к данным. Такие заявки ставят сотрудники клиента, но согласовывает их выполнение служба безопасности клиента или руководитель.

Вывод

OTRS — это очень перспективная система. Основным ее преимуществом, кроме того, что она бесплатная, является большая степень гибкости. Отсюда появляется и самый большой недостаток системы — ее нужно полностью настраивать и кастомизировать под свои задачи. Без этого удастся получить только базовый функционал. Очень мало компаний используют OTRS в базовом варианте, а некоторые создают собственные платные продукты на базе OTRS или же делают платные модули, отчеты и прочее.

Мы запустили готовую тикет-систему в облаке и предлагаем её в аренду.

Создайте тикет в «Спринтхост». Не знаете, что это такое? Сейчас расскажем!

Почему тикеты так называются?

Если верить переводу с английского, то ticket — это билет. Получается, что клиент, создавая тикет, покупает билет на помощь от техподдержки.

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

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

Тикет также можно перевести как «заявка». Как раз в этом значении чаще всего его и используют. Билет — это, конечно, хорошо. Можно даже представить, будто клиент получает билет на необычный поезд, экспресс в диковинные страны. Но все же «заявка» больше подходит для техподдержки.

Тикет крепко укоренился в терминологии клиентской службы, с которой знакомы не все, в особенности те, кто никогда не обращался в поддержку. Потому и появляются вопросы «а что такое тикет?» и «как его создать?». Но теперь вы на шаг ближе к истинному знанию.

Письмо в поддержку

Основная задача поддержки — отвечать на обращения пользователей, клиентов, случайного прохожего на сайте и даже «троллей». Естественно, должен быть какой-то канал связи, желательно, несколько. В Спринтхост это телефон, чаты на сайте, в Панели управления (ПУ), в телеграм-боте и группа в ВК, электронная почта.

«Чтобы открылось это окошко, нужно нажать на вопросик» — Фонд цитат Бэкапа

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

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

Чем хороша тикет-система?

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

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

Поднять историю можем не только мы, но и клиент. То есть если он помнит, что проблема уже когда-то возникала, то можно поискать ее решение в переписке. Да и вообще удобно написать сразу из Панели управления, получить там же ответ и все решить. Этакая небольшая соцсеть, только в рамках хостинга.

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

Вообще, тикетница крайне удобна в использовании — сразу видно, кто пишет, с какой просьбой и кто занимается этим обращением. Это также помогает руководству следить за качеством работы поддержки, направлять ее в нужное русло.

Тикеты шикарны в своем использовании. Слово крепко вошло в обиход не только хостинг-провайдеров, но и везде, где есть клиентская служба поддержки. Теперь вы знаете, что означает это слово, и можете успешно создавать новые «билеты» на экспресс «Экспресс-поддержка Спринтхост».

Что такое тикет?

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

Каждый тикет имеет свой статус:

«Новое» — тикет создан, но пока не принят в работу менеджером.

«Назначен исполнитель» — ваше сообщение направили на менеджера.

«Ждем уточнения» — менеджер написал уточняющий вопрос и ждет от вас ответа.

«Закрыт» — вопрос решен.

«Создано пожелание» — написанное вами пожелание по доработке программы передано инженерам на вопрос реализации.

«Создано обращение» — менеджер передал ваш вопрос инженеру для исправления ошибки.

Ответы на тикеты поступают в «Главное» → «Мои задачи».

Мои задачи

Как написать тикет?

1. Найдите в разделе «Главное» вкладку «Тикеты (техподдержка, пожелания, задачи)».

Тикеты

2. Кликните «Создать тикет».

Создание тикета

3. Открылась форма тикета — выберите тип обращения: консультация, пожелание или ошибка. Затем введите текст сообщения специалисту технической поддержки.

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

Вид тикета

Готово!

Вы написали тикет.

Еще четыре года назад команда техподдержки студии Plarium Krasnodar пользовалась сторонней тикет-системой, в чем было много минусов. Сегодня у нас появилась не просто своя система, а хелп-деск, подстроенный под нужды компании. Как это произошло — читайте в статье.

С чем же таким мы столкнулись, что задумались о разработке собственной тикет-системы? Основные проблемы:

  1. Наша команда получала по 1–1,5 тысячи обращений от пользователей каждый день.
  2. Отсутствовала удобная для ведения статистика. Все приходилось отмечать в различных документах вручную.
  3. Самая главная трудность: большая часть обращений требовала проверки состояния аккаунта игрока, к чему на тот момент не было непосредственного доступа — мы работали только с текстами самих обращений и электронными адресами.
  4. Создавал сложности и интерфейс. Чужие решения чаще всего неудобны, потому что не заточены под конкретную игру и регламенты работы техподдержки конкретной компании.
  5. С учетом всего перечисленного оплачивать стороннюю систему было не очень выгодно.

И мы принимаем эпохальное решение — пилить собственную тикет-систему! А что? Силы есть, идей полно, энтузиазма — хоть отбавляй. Но на тот момент не было соответствующих процессов: мы, команда поддержки, еще ни разу ничего не заказывали у команды разработки внутреннего инструментария и плотно с ней не взаимодействовали.

С чего ж начать? Мы выписали в два столбца наши ожидания от инструмента (что-то в стиле «хотим получать сообщения и отвечать на них») и претензии к текущему варианту («вот тут окошко неудобное, сделайте удобнее»), назвали это техническим заданием и передали разработчикам. Дальнейший процесс занял примерно 3 месяца. Этапа тестирования не было, все ошибки находили разработчики или сама поддержка уже при использовании инструмента.

Как можно догадаться, этот блин вышел комом. Мы попробовали его и подумали, что недостатки покупного сервиса не такие уж и серьезные. И тут команда коммерческого хелп-деска предложила интеграцию, при которой она получала бы из игры информацию о пользователе и передавала ее в тикетах нашей поддержке (это решило бы основную проблему). Однако некоторые условия интеграции противоречили требованиям безопасности, принятым в компании. Итак, какие у нас были варианты:

  1. Сделать небезопасную интеграцию с коммерческим хелп-деском.
  2. Пользоваться его урезанным функционалом.
  3. Вернуться к разработке инструмента.

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

В команду, которая обеспечивала процесс разработки, изначально вошли product owner, PM и dev-команда. Мы сами рисовали дизайн, разработчики редактировали и, насколько это было возможно, тестировали продукт. Затем присоединились UI/UX-дизайнеры и QA. В итоге получился следующий процесс: заказчик пишет, чего он хочет, UI/UX говорит, как это лучше сделать, разработчики реализуют, QA проверяет, а PM контролирует всю цепочку и сроки.

Введя тикет-систему с минимальным функционалом, мы начали ее улучшать и подгонять под нужды и цели компании. Всего до сегодняшнего дня было реализовано больше 200 фич, ниже перечислены основные.

  1. Сбор статистики по интересующим показателям. Исходя из целей и задач техподдержки, а также KPI для измерения эффективности работы, удалось определить, какая именно нам нужна статистика. Получилось 7 форм отчетов более чем по 50 показателям.
  2. Интерфейс обучения — чтобы новички могли безопасно (через апрув руководителя) работать с реальными обращениями пользователей.
  3. Программа автоматического ответа на обращение в зависимости от его содержания и темы.
  4. Жизненный цикл тикета с автоматическими переходами из одного статуса в другой по времени или событиям.
  5. HTML-верстка писем с красивым оформлением в стилистике проектов.
  6. Перенос самой важной информации об игроке прямо в тикет-систему. До этого специалисты поддержки могли видеть в ней только идентификатор-гиперссылку на другую программу с данными.
  7. Фильтрация и сортировка обращений.
  8. Участие игрока в оценке качества работы оператора. Мы сделали цикл отправки писем, сбор статистики и ее анализ.
  9. Функционал распределения доступов к элементам тикет-системы для разных операторов.
  10. Кабинет команды, которая оценивает качество работы операторов.
  11. Специальные технические статусы и состояния тикетов (не только классические new, waiting, answered, resolve и close, но и inprogress, pending). Они помогают операторам создавать добавочные фильтры; обозначают обращения, требующие дополнительной проверки и/или ожидающие ответа от других отделов; прерывают жизненный цикл тикета на время ожидания (не дают ему автоматически закрыться).
  12. Функция общения с игроком по инициативе техподдержки.
  13. Встроенная переписка с техподдержкой прямо в игре.
  14. Сервис, автоматически определяющий тему обращения по его тексту.
  15. Программируемые флоу — установленные решения, в том числе оповещение ответственных сотрудников, на случай того или иного события в тикет-системе.

Какой у нас был стек технологий:

  • написанное на C# приложение .NET Web API в качестве бэкенда;
  • клиент на Angular;
  • MongoDB в качестве базы данных и ElasticSearch для полнотекстового поиска;
  • Mailgun для рассылки сообщений на почту игрокам.


Общий вид Support Ticket System

Подведем итоги.

Плюсы от разработки собственной тикет-системы

  1. Заточенность хелп-деска под вашу компанию в решении задач, упрощении флоу и отслеживании показателей.
  2. Углубление знаний о функционировании тикет-системы и о работе команды техподдержки, сидящей через стенку от вас :)
  3. Независимость от обновлений стороннего хелп-деска. Всегда неприятно, когда из-за мешающего изменения приходится перестраивать процессы.
  4. Безопасность, которую дает отказ от использования сторонних решений в своих проектах.
  5. Заточенность интерфейса под ваши потребности.
  6. Быстрое внедрение уникальных фич. Например, ни в одном стороннем хелп-деске до сих пор нет удовлетворительной системы обучения новичков или пространства для команды, которая оценивает качество работы операторов.
  7. Возможности интеграции. Ваш продукт вырастает до экосистемы, объединяя в себе разные программы в одну цепочку, что оптимизирует работу сотрудников и дает более высокое качество.
  8. Новое направление работы, развитие сотрудников, бесценный опыт.
  9. Мотивация для всех причастных. Специалисты поддержки чувствуют, как их «хотелки» реализуются, а команды разработчиков, QA и UI/UX-дизайнеров видят, как их проект растет и какую пользу он приносит операторам.

Минусы

  1. В начале развития проекта затраты на разработку и поддержку системы превышают стоимость готового решения. Насколько — зависит от объема поступающих сообщений, величины команды поддержки и набора необходимых функций. Однако такой проект окупается. Если 40 операторов отправляют по 50–70 тысяч ответов в месяц, затраты на свою систему и на покупной сервис сравняются через 3–5 лет (в зависимости от стоимости этого сервиса). Если учесть эффект от плюсов собственной тикет-системы, ее гибкости, от функциональных элементов, оптимизирующих работу сотрудников, то окупаемость наступает года за два. После завершения разработки нужно платить только за хостинг, и это меньше, чем за сторонний хелп-деск.
  2. Придется пройти долгий и трудный путь. Мы несколько раз меняли процесс разработки, ругались и мирились, делали и переделывали. За эти 3,5 года было исправлено больше 1 500 багов.
  3. Понадобятся структурные изменения. Необходима команда, которая работает на обеспечение внутренних процессов. Нужно разделять тех, кто производит продукт компании, и тех, кто делает back office tools. Привлечь основных разработчиков к созданию такого инструмента вряд ли получится.

Ввязываться в это дело или нет — решать вам. А мы не жалеем, что ввязались. Было страшно. И страшно интересно было тоже :)

17 Ноября 2022


392



В избр.
Сохранено

Тикетная система: что это такое и зачем она нужна бизнесу

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

Что такое тикетная
система и как она работает

Это инструмент для организации работы с клиентскими обращениями, которые регистрируются в helpdesk’е. Сотрудники видят все заявки в одном окне, приоритезируют их, присваивают им теги, статусы и
категории, привлекают к обработке разных исполнителей и многое другое. Саппорту это позволяет работать быстро и качественно, а клиентам — точно знать, что на их запросы откликнутся.

Разберем механизм работы тикетной системы на примере helpdesk’а HappyDesk. Тикеты здесь создаются
автоматически, когда заявитель пишет компании на email или
регистрирует заявку на клиентском портале. Их также можно формировать вручную из
диалогов, которые появляются в системе, когда клиент отправляет сообщения в
мессенджеры или виджеты на сайте. Кроме того, тикет создается, если человек позвонил по телефону, а агент не успел ответить или разговор прервался.

Заявки попадают в список, который можно отсортировать по заявителям,
исполнителям, датам создания обращений, статусам и другим параметрам. Если
пользователю нужно найти конкретный запрос, он может вписать в строку поиска
его тему, номер или любое слово из текста переписки.


Так устроен список обращений в HappyDesk

Возможности и
преимущества тикетной системы

Небольшие компании, в саппорт которых поступает 2-3 заявки в день, обойдутся «ручными» методами — Excel-таблицей и аналогами или электронной почтой. Но если
обращений больше, этих инструментов недостаточно. И вот почему.

  • Обращения в почте остаются без ответа. Даже если сотрудник увидел письмо, есть большая
    вероятность того, что он не ответит сразу, и оно потеряется в общем
    потоке.
  • Таблицы не помогают. Данные в них нужно постоянно актуализировать — это отнимает время у операторов, и у них не остается ресурсов на то, чтобы помогать клиентам.
  • На типовые вопросы приходится отвечать вручную — это тоже требует много времени, а еще демотивирует агентов: кому захочется выполнять рутинную работу, которую легко автоматизировать.
  • У сотрудников размывается представление о своих зонах ответственности. Они не понимают, какие задачи в приоритете, какие можно делегировать, а какие уже неактуальны.
  • Продуктивность агентов практически невозможно оценить. Руководство не знает, с какой скоростью обрабатываются заявки, на каких этапах и почему возникают трудности.

А теперь перечислим ключевые функции тикетной системы.

  • Назначение тикетов на определенных сотрудников. Ускорить этот процесс помогают триггеры, или автоматические действия, которые срабатывают при определенных условиях. К примеру, можно настроить систему так, чтобы на все обращения, которые регистрируются на клиентском портале, сразу назначался оператор Сергей.
  • Эффективная обработка обращений. С помощью триггеров также можно автоматически отправлять уведомления операторам о появлении новых запросов в системе или комментариев к ним, смене их статусов и т. д. Заранее подготовленные шаблоны ответов вставляются в сообщение парой кликов и помогают быстро отвечать на типовые запросы. А еще к ним прикрепляются файлы: вместо длинного текста клиенту можно отправить инструкцию в формате PDF или видео. Лимиты для соблюдения SLA помогают повысить скорость работы агентов. Например, в системе можно создать счетчик «Эскалация по неответу», который лимит на первый ответ на обращение. Допустим, это 30 минут. Он будет запускаться сразу после того, как клиент отправит первое сообщение и остановится после того, как оператор опубликует первый ответ. Если он не успеет за обозначенные 20 минут, система его «поторопит».
  • «Умное» распределение заявок между сотрудниками. В HappyDesk можно задать максимальное число тикетов, которое будет находиться в работе у одного оператора. Ничего сверх этого к нему не попадет, а все новые запросы будут «висеть» в очереди до тех пор, пока кто-то не закроет очередную заявку.
  • Отчеты. Они формируются по обращениям и каждому конкретному специалисту и помогают узнать, сколько запросов и по каким каналам было отправлено в саппорт за определенное время, сколько времени сотрудники тратили на их закрытие и т. д.

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


В базе знаний легко найти всю нужную информацию о продукте, компании и т. д.

Как работать с тикетной системой

Одного наличия helpdesk’а с
тикет-системой недостаточно. И вот что важно сделать, чтобы инструмент
действительно приносил пользу.

  • Подключить к системе все каналы, которые нужны клиентам: почту, телефон, чаты на сайте, мессенджеры, соцсети и т. д. Чем больше способов связаться с компанией есть в наличии, тем больше клиентов останутся довольны сервисом.
  • Систематизировать процесс работы с заявками с помощью тегов, статусов, списков, приоритетов и других инструментов.
  • Подготовить шаблоны
    ответов на самые часто задаваемые вопросы. Если таких шаблонов будет много, в HappyDesk легко
    найти нужный с помощью строки поиска.
  • Анализировать основные показатели по работе с обращениями и при необходимости их улучшать.
  • Постоянно обучать
    операторов и не забывать правильно их мотивировать.

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

Понравилась статья? Поделить с друзьями:
  • Как написать тик току на почту
  • Как написать тик ток на китайском
  • Как написать тизер к тексту
  • Как написать тигр на английском
  • Как написать тз для создания сайта пример