How we stopped being afraid of tickets on UI

    Hello.
    More than a year has passed since we started using ReactJS in development. Finally, the moment has come to share how much happier our company has become. In this article I am going to talk about the reasons that prompted us to use this library and how we do it.

    Why all this


    We are a small company, our staff is about 50 people, 20 of which are developers. Now we have 4 development teams, each of which has 5 fullstack developers. But it’s one thing to call yourself a fullstack developer, and another is really good at understanding the intricacies of SQL Server’s work, ASP.NET, developing in C #, OOP, DDD, knowing HTML, CSS, JS and being able to use it all wisely. Of course, every developer gravitates to something different, but all of us, one way or another, are specialists in .NET development and 90% of the code we write in C #.
    Our product - a marketing automation system - implies a large amount of settings for each specific client. In order for our managers to be able to customize the product for customers, there is an administrative site where you can start mailings, create triggers and other mechanics, customize services and much more. This administrative site contains many different non-trivial UIs, and the finer the points we give to customize, the more features we release in production, the more interesting the UI becomes.

    Trigger creation


    Filter by Product Category


    How did we deal with the development of such a UI before? We coped poorly. Basically, they got off with rendering on the server pieces of HTML that received ajax. Or just on events using jQuery. For the user, this usually resulted in constant downloads, preloaders for each sneeze and strange bugs. From the point of view of the developer, these were the real pasta that everyone was afraid of. Any ticket on the UI in the planning immediately received an estimate of L and poured into a ton of buttonhole when writing code. And, of course, there were a lot of bugs related to such a UI. It happened like this: in the first implementation some minor mistake was made. And when repairing something else inevitably fell apart, because there were no tests for this miracle.
    An example from life. Here is the operation creation page. Without going into details about the business, I can only say that operations with us are something like REST services that our customers' contractors can use. The operation has restrictions on availability according to the stages of consumer registration, and in order to configure it, there was such a control:
    Create operation


    And here is the old code for this control:
    Operation availability indication control code
    Piece of view

    Доступность на этапах регистрации

    @Html.HiddenFor(m => m.IsAllowedForAllWorkflow, new { Id = "IsAllowedForAllWorkflow" })
    @Model.OperationWorkflowAllowances.Each( @)
    Механика регистрацииЭтап
    @item.Item.WorkflowDisplayName @(item.Item.StageName ?? "Любой")
    @if (Model.WorkFlows.Any()) {
    @Html.DropDownList("WorkflowList", Model.WorkFlows, new Dictionary { { "class", "form-control select2 w470" }, { "data-placeholder", "Выберите из списка" }, { "id", "workflowList" }, { "disabled", "disabled" } })
    @Html.DropDownList("WorkflowStageList", new SelectListItem[0], new Dictionary { { "class", "form-control select2 w470" }, { "data-placeholder", "Выберите из списка" }, { "id", "workflowStageList" }, { "disabled", "disabled"} })
    } else { @: Механики регистрации не зарегистрированы }

    And here is the js that made this view work (I did not pursue the goal of showing code that can be run, I just show how sad it was):
    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 =
                    '' : '>') +
                            '' +
                                    '{DisplayWorkflowName}' +
                                    '' +
                                    '' +
                                    '' +
                            '' +
                            '' +
                                    '' +
                                    '{DisplayStageName}' +
                                    '' +
                                    '' +
                    '' +
                    '';
            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(
                    '
    ' + 'Ошибка
    ' + message + '
    '); } 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(); }); });



    New Hope


    At some point, we realized that it was no longer possible to live like that. After some discussion, we came to the conclusion that we need a person from the side who understands the front end and directs us on the true path. We hired a freelancer who suggested we use React. He did not work very much with us, but managed to make a couple of controls to show what was happening, and the sensations turned out to be twofold. I really liked React since completing the tutorial on the official website.but not everyone liked it. In addition, hardcore front-end developers love javascript, but in the static-typed world of our development, javascript is not popular (to put it mildly), so all these webpacks and grunts that we were offered to use only scared us. As a result, it was decided to make several prototypes of a complex UI, using different frameworks in order to decide which one we need to deal with. Supporters of each of the frameworks from which we chose should have made a prototype of the same control so that we can compare the code. We compared Angular, React, and Knockout. The latter did not even go through the prototype stage, and I don’t even remember for what reason. However, between the supporters of Angular and React, the company launched a real civil war!
    A joke :) In fact, each framework had one supporter, everyone else did not like either one. Everyone hesitated and could not decide anything. In Angular, everyone was annoyed by its complexity, and in React, the dumb syntax, the lack of support of which in Visual Studio at that time was really a very unpleasant fact.
    Fortunately for us, our boss (one of the owners of the company) came to our aid, who of course has not programmed for a long time, but keeps his finger on the pulse. After it became clear that the prototypes did not give any effect, and the development spends time it is not clear what (at that moment we planned to make another prototype for a lot ofLarger size, so that there was more code to compare!), he had to make a decision. Now, recalling why his choice then nevertheless fell on React, Sasha agornik Gornik told me the following (I quote his words not for the holivar, this is just an opinion. Spelling, of course, is preserved, although I still corrected something) :
    There were several prototypes: a react, an angular, and something else. I watched. I did not like the angular, I liked the reaction.
    But [some] shouted the loudest, and everyone else was like vegetables. I had to read and watch.
    I saw that the reaction was in production on a bunch of cool sites. FB, Yahoo, WhatsApp and something else there. Obviously a huge adoption is coming and there is a future.
    And on the hangar - [nothing good]. Looked at the future. I saw that everything that I did not like in the prototype of the angular they want to strengthen in 2.0.
    I realized that react is a thing made for life that solves a specific problem. And angular - it's bearded theorists from Google from the brain come up with all sorts of concepts. As was the case with the GWT or whatever it is.
    Well, I realized that it was necessary to take the side of vegetables by a strong-willed decision, otherwise the screaming but wrong ones would win. Before doing this, I threw 33 million proofs and links into the channel, enlisted the support of [our chief architect] and tried to make sure that no one got hooked.
    And I also remembered what a hellishly important argument. For the reaction, there was a beautiful way to do it step by step and turn it into existing pages, and the angular required to redo them completely, and this also corrects with [its poor] architecture.
    Then I also read that in a reaction, in theory, a UI can not even be done for the web. And every server-side js / react there and where it all goes. And finally you couldn’t take a single argument.
    I realized that support for the studio will be cut very quickly. In the end, everything turned out exactly the same. I'm certainly hellishly happy with this decision)

    What happened?


    It's time to reveal the cards and show how we are cooking the UI now. Of course, front-end developers will now start laughing, but for us this code is a real victory, we are very happy with it :)
    For an example I will use the page for creating additional fields. Brief business information: some entities, such as Consumers, Orders, Purchases, and Products, may have some customer-specific information. In order to store such data, we use the classic Entity – attribute – value model . Initially, additional fields for each client were entered directly into the database (in order to save development time), but finally, time was also found for the UI.
    Here's what the page for adding an additional field in the project looks like:
    Adding an additional field of type Enumeration


    Adding an additional field of type String


    And here is what the code for this page looks like on React:
    Component of the page for adding / editing additional fields
    /// 
    module DirectCrm
    {
            export interface SaveCustomFieldKindComponentProps extends Model
            {
            }
            interface SaveCustomFieldKindComponentState
            {
                    model?: CustomFieldKindValueBackendViewModel;
                    validationContext: IValidationContext;
            }
            export class SaveCustomFieldKindComponent extends React.Component
            {
                    private _componentsMap: ComponentsMap;
                    constructor(props: SaveCustomFieldKindComponentProps)
                    {
                            super(props);
                            this.state = {
                                    model: props.model,
                                    validationContext: createTypedValidationContext(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 (
                                    
    m.entityType)}>
    m.typedViewModel)} onChange={getPropertySetter( this.state.model, this._setModel, viewModel => viewModel.typedViewModel)} value={this.state.model.typedViewModel} constantComponentData={componentInfo.constantComponentData} /> viewModel.isMultiple)} disabled={false} /> {this._renderShouldBeExportedCheckbox()}
    ); } _getViewModelValue = () => { var clone = _.clone(this.state.model); clone.componentsMap = null; clone.entityTypes = null; return clone; } render() { return (
    {this._renderComponent() }
    ); } _renderShouldBeExportedCheckbox = () => { if (this.state.model.entityType !== "HistoricalCustomer") return null; return ( m.shouldBeExported)}> viewModel.shouldBeExported)} disabled={false} /> ); } } }


    TypeScript


    “What was that?” You may ask if you were expecting to see javascript. This is tsx - a variant of React's jsx under TypeScript. Our UI is fully statically typed, no “magic lines”. Agree, this could be expected from such hardcore back-endors as us :)
    Here a few words need to be said. I have no goal of raising a holivar on the topic of statically and dynamically typed languages. It just so happened that in our company no one likes dynamic languages. We believe that they can’t beit’s very difficult to write a large supported project that refactors for years. Well, it's just hard to write, because IntelliSense doesn’t work :) This is our belief. One can argue that everything can be covered with tests, and then it will be possible with a dynamically typed language, but we will not argue on this topic.
    The tsx format is supported by the studio and the new R #, which is another very important point. But a year ago in the studio (not like in R #) there was not even support for jsx, and for development on js we had to have another code editor (we used Sublime and Atom). As a result of this, half of the files were not enough in the studio Solution, which only added butcherts. But let's not talk about it, because happiness has already come.
    It should be noted that even typescript in its pure form does not give the level of static typing that we would like. For example, if we want to set some property in the model (actually bind the UI controller to some model property), we can write a callback function for each such property for a long time, and we can use one callback that takes the name of the property, which is never statically typed. Specifically, this problem we have solved with approximately this code (you can see examples of using getPropertySetter above):
    /// 
    function getPropertySetter(
            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(obj: TObject, expression: {(obj: TObject): any}): string
    {
            return getPropertyNameByPropertyProvider(expression);
    }
    function getPropertyNameByPropertyProvider(propertyProvider: Function): string
    {
            return /\.([^\.;]+);?\s*\}$/.exec(propertyProvider.toString())[1];
    }
    

    There is no doubt that the implementation of getPropertyNameByPropertyProvider is very, very dumb (you won’t even pick another word). But typescript does not provide another choice yet. ExpressionTree and nameof are not in it, and the positive sides of getPropertySetter outweigh the negative sides of such an implementation. In the end, what could happen to her? It can start to slow down at some point, and it will be possible to attribute some caching there, or maybe by then some kind of nameof will be done in typescript.
    Thanks to such a hack , for example, we have renaming throughout the code and we don’t have to worry about something falling apart somewhere.
    Otherwise, everything works just magically. Did not specify any required prop for the component? Compilation error. Passed prop of the wrong type to the component? Compilation error. No stupid PropTypes with their runtime warnings. The only problem here is that we still have the backend in C #, not typescript, so each model used on the client needs to be described twice: on the server and on the client. However, there is a solution to this problem: we ourselves wrote a prototype type generator for typescript from types on .NET after we tried open source solutions that did not satisfy us, but then read this article . It looks like you just need to apply this utility somehow and see how it behaves in combat conditions. Apparently, everything is already fine.

    Component rendering


    I’ll tell you in more detail how we initialize the components when opening the page and how they interact with the server code. I’ll immediately warn you that the kapling is quite high, but what can I do.
    For each component on the server there is a view model on which this component will bind during a POST request. Usually the same view model is used to initialize a component from the very beginning. Here, for example, is the code (C #) that initializes the view model of the extra fields page shown above:
    View model initialization code on the server
    public void PrepareForViewing(MvcModelContext mvcModelContext)
    {
    	ComponentsMap = ModelApplicationHostController
    		.Instance
    		.Get()
    		.GetNamedObjectRelatedComponentsMapFor(
    			customFieldViewModel => customFieldViewModel.PrepareForViewing(mvcModelContext));
    	EntityTypes = ModelApplicationHostController.NamedObjects
    		.GetAll()
    		.Select(
    			type => new EntityTypeDto
    			{
    				Name = type.Name,
    				SystemName = type.SystemName,
    				Prefix = type.ColumnPrefix
    			})
    		.ToArray();
    	if (ModelApplicationHostController.NamedObjects.Get().Sku.IsEnabled())
    	{
    		EntityTypes =
    			EntityTypes.Where(
    				et => et.SystemName != ModelApplicationHostController.NamedObjects
    											.Get().Purchase.SystemName)
    				.ToArray();
    	}
    	else
    	{
    		EntityTypes =
    			EntityTypes.Where(
    				et => et.SystemName != ModelApplicationHostController.NamedObjects
    											.Get().Sku.SystemName)
    				.ToArray();
    	}
    	if (FieldType.IsNullOrEmpty())
    	{
    		TypedViewModel = new StringCustomFieldKindTypedViewModel();
    		FieldType = TypedViewModel.Type;
    	}
    }
    


    Here, some properties and collections are initialized, which will be used to populate the lists.
    In order to draw some component using the data of this view model, the Extension method HtmlHelper is written. In fact, in any place where we need to render a component, we use the code:
    @Html.ReactJsFor("DirectCrm.SaveCustomFieldKindComponent", m => m.Value)
    

    The first parameter is the name of the component, the second is PropertyExpression - the path in the view model of the page where the data for this component is located. Here is the code for this method:
    public static IHtmlString ReactJsFor(
    	this HtmlHelper htmlHelper,
    	string componentName,
    	Expression> 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(
    		"
    ", HttpUtility.HtmlEncode(componentName), HttpUtility.HtmlEncode(htmlHelper.NameFor(expression)), HttpUtility.HtmlEncode(modelData), HttpUtility.HtmlEncode(validationData), HttpUtility.HtmlEncode(initializeData))); }

    In fact, we just render the div, which contains the data necessary for rendering the component in the attributes: the name of the component, the path in a more global model, the data by which the component will be initialized, server validation messages, as well as some additional data for initialization. Further, when rendering a page due to a simple component, the component will be rendered into this 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
    		);
    	});
    }
    

    Thus, the main components that store the main state of the page are rendered - that is, in most cases, these components generally have state. The components embedded in them usually either have no state at all, or their state is not important within the page (such as the open / close flag of the drop-down menu in select).

    Binding


    Great, we drew the component, but how will the data get back to the server?
    It's pretty simple. At least as a first approximation. Most pages are quite simple and use a regular post form. The controllers in the components do not have names, and the binding occurs due to the fact that with any change in the state of the main component (and it actually stores the state of the entire page, as I said above), a special hidden input is rendered, containing the current state of the model serialized in json. In order for this json to bind to our ASP.NET application, a special ModelBinder was written.
    Let's start with hidden input. Each page component contains the following component:

    Its code is pretty simple:
    class HiddenInputJsonSerializer extends React.Component<{ model: any, name: string }, {}> {
    	render() {
    	    var json = JSON.stringify(this.props.model);
    		var name = this.props.name;
    		return (
    			
    		);
    	}
    }
    

    When posting a form, we actually post one value - a huge json with the name that appeared in this.props.modelName - and this is the very name that we passed to data-react-model-name when rendering (see above), that is the text path in some large view model to our view model, which will arrive json.
    In order for this json to bind to the view model in the application, the following code is used. To begin with, the properties of the view models that we want to get from json should be marked with a special JsonBindedAttribute. Below is the code of the parent view model, in which the view model is embedded, which will be bound from json:
    public class CustomFieldKindCreatePageViewModel : AdministrationSiteMasterViewModel
    {
    	public CustomFieldKindCreatePageViewModel()
    	{
    		Value = new CustomFieldKindValueViewModel();
    	}
    	[JsonBinded]
    	public CustomFieldKindValueViewModel Value { get; set; }
    	/// другие свойства и методы родительской вью-модели
    }
    

    Now you need something to take advantage of this information and try to populate the CustomFieldKindCreatePageViewModel.Value property from the string. This is something - ModelBinder. The code is quite logical: if the property is marked JsonBindedAttribute - find the value with the appropriate name in the form data and deserialize it as CustomFieldKindValueViewModel (in this case). Here is his code:
    Binder code that deserializes 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().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())
    				.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));
    			}
    		}
    	}
    }
    


    Note that if we expected the property to be bound from json and json didn’t come, we will fall, because with a 99.9% probability some kind of error occurred on the client, because of which the component was not even rendered. Either we made a mistake when popping the name into the component, but such an error is usually caught at the development stage.
    Unfortunately, it is impossible to instantly rewrite the entire code base to a new framework, and a fairly large number of pages still use html, rendered on the server, and react components simultaneously. There are situations when some piece of the page is rendered by react, and inside this piece, the part is rendered on the server, and inside this piece, the part is again rendered by react. Such complexity arose, for example, on the page for creating a trigger. I cited it above, but just in case I will give her a screenshot again here:
    Trigger Creation Page


    The whole page is one big component, but the first arrow points to the Filter component, which was made on pure js a few years ago, and rewriting it on react is a monthly task. At the same time, the js that draws the filter actually draws html from the server, only the general logic of the control is written in js. However, since a large filter consists of a smaller set of filters, and some of the filters have a rather nontrivial UI, you need to be able to create such filters using react. The second arrow points to such a filter in essence, "Action Template", it is made as a react component.
    How does a structure of this structure bind? In order for this to work, each input inside the filter must have a correctly filled name, the prefix of which must be dragged through an external component written in react. One of these inputs can be our hidden input, which stores the state of some complex internal filter. However, all the values ​​of ordinary controllers that came in the POST request would be simply ignored, since the view model containing the page state is marked with JsonBindedAttribute, which means that it and all objects embedded in it should simply be serialized from json. In order to fill part of such a view model from the usual form data, its internal property must be marked FormBindedAttribute, and when deserializing from json, you need to use FormBindedConverter,
    FormBindedConverter Code
    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());
    		foreach (var formBindedProperty in formBindedProperties)
    		{
    			var formBindedPropertyName = formBindedProperty.Name;
    			var formBindedPropertyFullPath = $"{formNamePrefix}.{currentJsonPath}.{formBindedPropertyName}";
    			var formBindedPropertyModelBinderAttribute =
    				formBindedProperty.PropertyType.TryGetSingleAttribute();
    			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);
    	}
    }
    


    This converter monitors the nesting chain of view models during deserialization from json, and also looks at deserializable types for the presence of FormBindedAttribute. If some property is marked with such an attribute, then we find out which binder should be used to get this property from the form data, instantiate this binder and ask it to fill out the desired property.
    Thus, when binding a fairly complex model, we get to MindboxDefaultModelBinder, from which we get to FormBindedConverter, from which we get to FilterViewModelBinder, from which we again get to MindboxDefaultModelBinder.

    Polymorphic View Models


    In our UI, it often happens that some part of the component changes from the choice of the value of the drop-down list. For example, take the same page for adding additional fields:
    Adding an integer field


    Adding a String Field


    Adding an Enumeration


    Depending on the type of field, it is necessary to display a different UI. This problem can be solved by writing switch by field type, but I like a more polymorphic approach. As a result, for such cases for each value there is a certain component in it, which is rendered if the corresponding value is selected. Here is the code for these components:
    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;
                return (
                    
    {super.render() } m.validationStrategySystemName) } >
    this.props.onChange(vm), m => m.validationStrategySystemName) } options={stringConstantData.validationStrategies} disabled={this.props.disabled}/>
    ); } } }

    module DirectCrm {
        export class DefaultCustomFieldKindComponent extends CustomFieldKindComponentBase {
        }
    }
    

    module DirectCrm {
        export class CustomFieldKindComponentBase extends React.Component {
            render() {
                return 
    {this.renderTooltip() }
    } renderTooltip() { return } } }

    How, depending on the selected value of the type, does the necessary component for rendering be selected?
    This can be seen in the component code of the entire page, I will give the necessary piece here again:
    _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 (
    		
    // другие части страницы 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}/>
    // другие части страницы
    ); }

    As you can see from the code, a certain TypedComponent is rendered, which was obtained by some manipulations with the _componentsMap object. This _componentsMap is just a dictionary where the type values ​​(selected in the “field type” drop-down) correspond to componentInfo objects that store data specific to a particular typed component: the component factory itself, constant data (lists, urls up to some important components services), as well as a string representation of the .NET type, which will be necessary in order to properly deserialize this view model. The structure of _componentsMap in json is presented below:
    ComponentsMap structure
    "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"
             }
          }
       }
    


    Who creates this dictionary? It is created by server code based on a special configuration. Here is the code that ComponentsMap creates when initializing the base view model on the server:
    public void PrepareForViewing(MvcModelContext mvcModelContext)
    {
    	ComponentsMap = ModelApplicationHostController
    		.Instance
    		.Get()
    		.GetNamedObjectRelatedComponentsMapFor(
    			customFieldViewModel => customFieldViewModel.PrepareForViewing(mvcModelContext));
    	// ещё какая-то инициализация
    }
    

    In order for ReactComponentViewModelConfiguration to know which view models correspond to the base CustomFieldKindTypedViewModelBase, they must be registered in advance. Registration code looks simple:
    configuration.RegisterNamedObjectRelatedViewModel(
    	() => new StringCustomFieldKindTypedViewModel());
    configuration.RegisterNamedObjectRelatedViewModel(
    	() => new IntegerCustomFieldKindTypedViewModel());
    configuration.RegisterNamedObjectRelatedViewModel(
    	() => new EnumCustomFieldKindTypedViewModel());
    

    Further, this property of the view model simply hits the client in the same way as everyone else. At the same time, the name of the component on the client is part of the view model of the heiress in C # code. As I said, kapping is quite high.

    Validation


    Our application gets data from many different sources:
    • we ourselves use the services of contractors
    • our contractors use our services
    • our administrative site is a data source

    Regardless of how exactly the data gets into our system, there are some business rules for the domain model, the consistency of which we need to maintain. These business restrictions are located within the domain model itself and are implemented by one of the variations of the Notification pattern . A separate article can be devoted to the architecture of our validation, so I will not describe it in detail now. I can only say that since the validation is inside the domain model, and I don’t want to duplicate the code, it is necessary to drag the validation messages to the client after they appear. Also on the client it is necessary to have a certain framework that allows displaying validation messages next to the controllers containing invalid data.
    Let's start with the client side. Validation messages arrive in the main component when it is rendered on the server in data-react-validation-summary (see ReactJsFor code above). Validation summary is a hierarchical json, where the name of each property of the validated view model corresponds to a validation error (if any), or an object that contains validation errors of nested view models. For example, I’ll show the value of validationSummary for the situation in the screenshot below:
    Failed to save additional field


    The twist of the validation message inside the table of enumeration values ​​has collapsed a bit, but we see that there are some errors when saving.
    Here is the validation summary for this case:
    {  
       "typedViewModel":{  
          "selectedEnumValues[0]":{  
             "systemName":[  
                "Идентификатор значения перечисления должен быть короче 250 символов"
             ]
          }
       },
       "name":[  
          "Имя обязательно"
       ]
    }
    

    Now all we need on the client is to be able to navigate this object and display validation errors, if any. To achieve this, a ValidationContext is used, which is passed a validation summary during creation, and which has the following interface:
    interface IValidationContext
    {
    	isValid: boolean;
    	getValidationMessageFor: { (propertyExpression: {(model: TViewModel):any}): JSX.Element };
    	validationMessageExpandedFor: { (propertyExpression: {(model: TViewModel):any}): JSX.Element };
    	getValidationContextFor: { (propertyExpression: {(model: TViewModel):TProperty}): IValidationContext };
    	getValidationContextForCollection: { (propertyExpression: {(model: TViewModel):TProperty[]}): {(index: number): IValidationContext} }
    }
    

    As you can see, it is fully statically typed. Let's evaluate this using an example. Here's how this context is used to display a validation message for the Name field:
     m.name) }>
    	 viewModel.name) } />
    

    In this example, this.state.validationContext is of type IValidationContext, за счёт чего достигается статическая типизация при выборе свойства модели. Причем для достижения такого эффекта даже не используется злополучная getPropertyNameByPropertyProvider, описанная выше, так как на самом деле нужно просто выполнить переданную в getValidationMessageFor функцию над текущим состоянием validation summary и посмотреть на результат.
    Теперь вкратце расскажу, как формируется объект validation summary на сервере.
    Так как сама валидация происходит в доменной модели, то необходимо как-то связывать валидационные собщения с источниками данных, которые к этим валидационным сообщениям привели. Каждое валидационное сообщение связывается со специальным объектом, называемым ключом валидации, а конкретные ключи валидации связываются с источниками данных для этих ключей. В административном сайте источниками данных являются контроллы на странице, а если говорить с точки зрения серверного кода — свойства вью-моделей. То есть ключу валидации фактически ставится в соответствие путь от корня вью-модели до её свойства какой-либо вложенности. Этот путь в итоге хранится строкой, в которой имена свойств разделяются точками, а для индексации используются квадратные скобки. Всё, что нам нужно — попытаться сохранить, и если это не удалось, собрать валидационные сообщения, хранящие такие пути и валидационные ошибки, и преобразовать подобные пути в формат, отвечающий требованиям validation summary.
    Вот как выглядит связывание пути во вью-модели с ключом валидации для поля «Имя» из примера выше:
    private void RegisterEndUserInput(
    	ISubmodelInputRegistrator 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(c => c.NameRequired);
    	// другие цепочки валидации
    }
    

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

    В заключение


    In this article, I tried to describe in as much detail as possible how our architecture for working with the UI is arranged. It has both obvious advantages in the form of high-quality validation in the domain model, static typing, and obvious disadvantages, some of which I ignored :)
    In any case, I hope that this article first makes you think about Use the new UI frameworks, even if you have a harsh Enterprise. It is not very important what exactly to use. We like ReactJS more, but maybe something else is right for you. Secondly, I hope that this article will spur those who saw space for improvement in it, do not be shy and suggest methods to make our code better! I really hope for constructive criticism and advice from the community.

    Also popular now: