📜 ⬆️ ⬇️

How we stopped being afraid of UI tickets

Hello.
More than a year has passed since we started using ReactJS in development. Finally the moment came to share how much happier our company has become. In the 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 one thing is to call yourself a fullstack developer, and another is really good at understanding the intricacies of SQL Server, ASP.NET, developing in C #, OOP, DDD, knowing HTML, CSS, JS and being able to use it all wisely. Of course, every developer has something to do with his own, but all of us, anyway, are experts in .NET development and we write 90% of the code in C #.
Our product - 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 clients, there is an administrative site in which you can start mailing lists, create triggers and other mechanics, customize services and much more. This administrative site contains many different nontrivial UIs, and the more subtle moments we give to customize, the more features we release in production, the more interesting the UI becomes.

Create trigger


Filter by product categories


How did we cope with the development of such a UI before? We coped badly. Basically, I got off with rendering on the server pieces of HTML that were received by ajax. Or just on events using jQuery. For the user, this usually resulted in constant downloads, preloaders for each and strange bugs. From the point of view of the developer, these were the real macaroni that everyone feared. Any ticket on the UI on the planning immediately received an estimate of L and poured into a ton of the bathert while writing the code. And, of course, there were a lot of bugs associated with such UI'em. It happened this way: in the first implementation some kind of small mistake was made. And when repairing something else was inevitably falling apart, because there were no tests for this miracle.
An example from life. This is the operation creation page. Without going into details on business, I will only say that our operations are something like REST services that our customers' contractors can use. The operation has restrictions on availability according to the stages of registration of consumers, and in order to configure this, there was such a control:
Creating an operation


And here is the old code for this controller:
Kontolla code indicating the availability of operations
View slice
<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> 

But js, which made this view work (I didn’t want to show the 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 = '<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(); }); }); 



New Hope


At some point we realized that it was no longer possible to live this way. After some discussion, we came to the conclusion that we need a person from the outside, who understands the front end and guides us on the right path. We hired a freelancer who suggested we use React. He did not work very hard with us, but he managed to make a couple of controls to show what was happening, and the sensations turned out to be twofold. I really liked React from the moment of the tutorial on the official site , but not everyone liked it. In addition, hardcore front-end users love javascript, but in the statically typed world of our development, javascript is not popular (to say the least), so all the webpacks and grunt that we were offered to use just scared us. As a result, it was decided to make several prototypes of a complex UI, using different frameworks in order to decide which ones we need to deal with. Proponents of each of the frameworks from which we chose, had to make a prototype of the same control, so that we could 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 supporters of Angular and React, a real civil war broke out in the company!
Joke :) In fact, each framework had one supporter, everyone else did not like one or the other. All hesitated and could not solve anything. In Angular, everyone was annoyed by its complexity, and in React, by the dumb syntax, the lack of support for which in Visual Studio at that time was indeed 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 been programming for a long time, but keeps abreast. After it became clear that the prototypes did not produce any effect, and the development spends time is not clear on what (at that moment we planned to make another prototype a lot more, to have more code for comparison!), We had to make a decision his. Now, remembering why his choice then fell on React, Sasha agornik Gornik told me the following (I cite his words not for holivar, this is just an opinion. The spelling, of course, is preserved, although I corrected something) :
There were several prototypes: reactor, angular, and something else. I watched. I did not like Angular, I liked the reactor.
But [some] shouted the loudest, and everyone else was like vegetables. I had to read and watch.
I saw that the reactor was in production on a bunch of cool sites. FB, Yahoo, WhatsApp and something else there. Obviously, a huge admission is coming and there is a future.
And on angular - [nothing good]. I looked at the future. I saw that everything that I didn’t like in the prototype of the angulyar was strengthened in 2.0.
I understood that react is a thing for life made solving a specific problem. And the angular is the bearded theorists from Google from the brain come up with all sorts of concepts. As was the case with GWT or how it is there.
Well, I realized that we need a strong-willed decision to take the side of vegetables, otherwise the screaming, but wrong will win. Before I did this, I threw 33 million proofs and links into the channel, I enlisted the support of [our chief architect] and tried to make sure that nobody would stop.
And I remembered how hellishly important the argument was. For the reactor, there was a beautiful way to do it step by step and screw it into existing pages, and the angular required to redo them entirely, and this also corrected with [its bad] architecture.
Then I also read that in theory, in theory, you can even use the UI for the web. And every server there js / react and where it all goes. And finally, not a single argument was left to take.
I realized that support for the studio was done very quickly. In the end, everything exactly happened. I'm certainly hellishly pleased with this decision)

What happened?


It's time to reveal the cards and show how we are cooking the UI now. Of course, the front-end players will start laughing now, but for us this code is a real victory, we are very pleased with it :)
For example, I will use the page for creating additional fields. Brief Business Note: Some entities, such as Consumers, Orders, Purchases and Products, may have some customer-specific related data. In order to store such data, we use the classic Entity – attribute – value model . Initially, additional fields for each client were started right in the database (in order to save development time), but finally there was also time for the UI.
Here is the page for adding an additional field in the project:
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
 /// <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


“What was it?” - you can ask if you expected to see javascript. This is a tsx version of React jsx under TypeScript. Our UI is completely statically typed, no “magic strings”. Agree, this could be expected from such hardcore backendors like us :)
Here you need to say a few words. I have no goal to raise 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 it is not very difficult to write a large, supported project for them, which will refactor over the years. Well, it's hard to just write, because IntelliSense does not work :) This is our conviction. 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 just in R #) there was no support even for jsx, and for developing on js, you had to have another code editor (we used Sublime and Atom). In consequence of this, half of the files were missing in the Studio Solution, which only added batherths. 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 a property in the model (actually, we can reduce the UI controller to some property of the model), we can write a callback function for each such property that takes a long time, and we can use one callback that takes the name of the property, which is never statically typed. Specifically, we have solved this problem with something like this code (you can see examples using getPropertySetter above):
 /// <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]; } 

There is no doubt that the implementation of getPropertyNameByPropertyProvider is very, very dumb (you can't even pick up another word). But there is no other choice typescript yet. ExpressionTree and nameof are not there, and the positive aspects of getPropertySetter outweigh the negative aspects of such an implementation. In the end, what could happen to her? It may start to slow down at some point, and it will be possible to assign some caching there, and maybe by that time some nameof in typescript will be done.
Thanks to this hack , for example, renaming works on the whole code and we don’t have to worry about something falling apart somewhere.
Otherwise, everything works just magically. Did you specify any required prop for the component? Compilation error. Did you transfer the prop of the wrong type to the component? Compilation error. No stupid PropTypes with their warnings in runtime. The only problem here is that the backend is still in C # and not in typescript, so each model used on the client needs to be described twice: on the server and on the client. However, a solution to this problem exists: we ourselves wrote a prototype of the type generator for typescript from types on .NET after trying 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 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. Immediately I warn you that the capling is quite high, but what to do.
For each component on the server there is a model-view for which this component will be squeezed during a POST request. Usually, the same model view is used to initialize the component initially. For example, here is the code (C #) that initializes the page view of the additional fields shown above:
Initialization code for the view model on the server
 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; } } 


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

The first parameter is the name of the component, the second is the 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<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


, , ?
It's pretty simple. . . , , ( , ), 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 , , .

Finally


, UI. , , , :)
, , - , UI , Enterprise. , . ReactJS, - . -, , , , ! .

Source: https://habr.com/ru/post/276235/


All Articles