dsuryd/dotNetify-Elements

Strange behavior submitting a form

jersiovic opened this issue · 15 comments

This sample shows a strange behavior I'm experiencing when a form is submitted after is is loaded.
The view:

import React from 'react';
import PropTypes from 'prop-types';
import dotnetify from 'dotnetify';
import { Scope } from 'dotnetify';
import { withTheme, VMContext, Form, Element, Panel, TextField, Button  } from 'dotnetify-elements';

dotnetify.debug = true

class TestForm extends React.Component {
    state = {};
    
    render() {
        return (
            <VMContext vm="TestVM">
                <TextField label="IdToLoad" id="IdToLoad" />
                <Form>
                    <Panel>
                        <Panel horizontal>
                            <Button label="Save" submit id="SubmitUpdate" />
                        </Panel>
                    </Panel>
                    <Panel >
                        <TextField label="Id" id="Id"  />
                        <TextField label="Description" id="Descrip" />
                        <TextField label="Observations" id="Observations" />
                    </Panel>
                </Form>
            </VMContext>
        );
    }
};
export default withTheme(TestForm);

The viewmodel:

public class TestVM : BaseVM
    {
        TestDTO myTestDto = new TestDTO() { Id = 2, Descrip = "Test", Observations = "Observa Test" };
        private readonly IServiceProvider _serviceProvider;

        public TestVM(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
            
            var id = AddProperty<int>("Id");
            var descrip = AddProperty<string>("Descrip");
            var observations = AddProperty<string>("Observations");
            
            var idToLoad = AddProperty<int>("IdToLoad");
            idToLoad.WithServerValidation((i) => true,"Error");
            idToLoad.Subscribe(i =>
            {
                if (i == default(int))
                {
                    id.Value = null;
                    descrip.Value = null;
                    observations.Value = null;
                    return;
                }

                id.Value = myTestDto.Id;
                descrip.Value = myTestDto.Descrip;
                observations.Value = myTestDto.Observations;
                PushUpdates();
            });

            AddProperty<TestDTO>("SubmitUpdate")
                .Subscribe(formData =>
                {
                    if (formData.Id==0)
                        throw new Exception("Why Id is not initialized?");
                });
        }
}
public class TestDTO 
    {
        public int Id { get; set; }
        public string Descrip { get; set; }
        public string Observations { get; set; }
    }

If I set an int on IdToLoad textfield (no matter which) and press enter it loads properly data on the form. The strange thing happens after I modify Descrip and click on Save. You will see data formData sumbitted to server on SubmitUpdate property is not the current data in the form, Id is 0 when it should be 2, and Observations are null when should be "Observa Test". The only valid data is the one you input (Descrip. In fact if you edit all of them the data edited will be send properly to server side.
The expected behavior is data is submited as it is on the view.

Thank you

The form is designed to accept and submit user input. It will only submit data that's changed through UI, or the default values that was set before the form enters edit mode.

In order for that form to submit the loaded data from the server, make the form waits until it gets the default values before going to edit mode. For example:

class Test extends React.Component {
  state = { currentId: 0 };

  render() {
    return (
      <VMContext vm="TestVM" onStateChange={state => this.setState({ currentId: state.IdToLoad })}>
        <TextField label="IdToLoad" id="IdToLoad" />
        <Form plainText={this.state.currentId == 0}>
          <Panel>
            <Panel horizontal>
              <Button label="Save" submit id="SubmitUpdate" />
            </Panel>
          </Panel>
          <Panel>
            <TextField label="Id" id="Id" />
            <TextField label="Description" id="Descrip" />
            <TextField label="Observations" id="Observations" />
          </Panel>
        </Form>
      </VMContext>
    );
  }
}

Or only show the form after the data is loaded:

...
{this.state.currentId && (
   <Form>
   ...

Understood, thank you another time :)

Problem is I want to be able to edit when currentId = 0 cause it is used for adding new TestDTO. However with you solution when state.IdToLoad === 0 form is hidden.

Maybe it doesn't have many sense for you, but take into account this is a simplification. In my real scenario I have two components within my forms: NavigationToolbar and CRUDToolbar that will be reused across all may forms. Thosey communicate with parent component through handlers provided by the parent for loading a TestDTO with the id requested by them. Those handlers use hidden IdToLoad field to force load of an existing TestDTO or a new one if they request one with Id=0.

Forget about that. Using int? for idtoload and initializing currentId to null instead of 0 it works

Still same problem if there is an async call on the viewmodel when loading data:
The view:

import React from 'react';
import PropTypes from 'prop-types';
import dotnetify from 'dotnetify';
import { Scope } from 'dotnetify';
import { withTheme, VMContext, Form, Element, Panel, TextField, Button  } from 'dotnetify-elements';

dotnetify.debug = true

class TestForm extends React.Component {
    state = { currentId: null };
    
    render() {
        return (
            <VMContext vm="TestVM" onStateChange={state => this.setState({ currentId: state.IdToLoad })} >
                <TextField label="IdToLoad" id="IdToLoad" />
                {this.state.currentId &&
                    <Form>
                        <Panel>
                            <Panel horizontal>
                                <Button label="Save" submit id="SubmitUpdate" />
                            </Panel>
                        </Panel>
                        <Panel >
                            <TextField label="Id" id="Id" />
                            <TextField label="Description" id="Descrip" />
                            <TextField label="Observations" id="Observations" />
                        </Panel>
                    </Form>
                }
            </VMContext>
        );
    }
};
export default withTheme(TestForm);

The ViewModel

public class TestVM : BaseVM
    {
        TestDTO myTestDto = new TestDTO() { Id = 2, Descrip = "Test", Observations = "Observa Test" };
        private readonly IServiceProvider _serviceProvider;

        public TestVM(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
            
            var id = AddProperty<int>("Id");
            var descrip = AddProperty<string>("Descrip");
            var observations = AddProperty<string>("Observations");
            id.Value = 0;
            descrip.Value = "";
            observations.Value = "";

            var idToLoad = AddProperty<int?>("IdToLoad");
            idToLoad.WithServerValidation((i) => true,"Error");
            idToLoad.Subscribe(async i =>
            {
                if (i == default(int?) || (i) == 0)
                {
                    id.Value = 0;
                    descrip.Value = "";
                    observations.Value = "";
                    return;
                }
                await Task.Run(() =>
                {
                    Thread.Sleep(2000);
                    return "Async work complete";
                });
                id.Value = myTestDto.Id;
                descrip.Value = myTestDto.Descrip;
                observations.Value = myTestDto.Observations;
                PushUpdates();
            });

            AddProperty<TestDTO>("SubmitUpdate")
                .Subscribe(formData =>
                {
                    if (formData.Id==0)
                        throw new Exception("Why Id is not initialized?");
                });
        }

        public object Assert { get; }

        private IAppServiceWithIntKey<FamilyDTO> GetAppService()
        {
            return (IAppServiceWithIntKey<FamilyDTO>)_serviceProvider.GetService(typeof(IAppServiceWithIntKey<FamilyDTO>));
        }
    }

However if I remove the async and make the Task run synchronous it works properly.

Task.Run(() =>
                {
                    Thread.Sleep(2000);
                    return "Async work complete";
                }).GetAwaiter().GetResult();

Another problem I have related with the solution you propose is if I try to reuse the same form not only for updating existing items but also for adding new ones.
I change the view tohide the form when IdToLoad is null and I add a button for adding new items that will set to 0 IdToLoad. This will load default values in the form.

This is the modified view:

import React from 'react';
import PropTypes from 'prop-types';
import dotnetify from 'dotnetify';
import { Scope } from 'dotnetify';
import { withTheme, VMContext, Form, Element, Panel, TextField, Button  } from 'dotnetify-elements';

dotnetify.debug = true

class TestForm extends React.Component {
    state = { currentId: null };
    addNewHandler = _ => {
        this.IdToLoad.dispatch(0);
    }

    render() {
        return (
            <VMContext vm="TestVM" onStateChange={state => this.setState({ currentId: state.IdToLoad })} >
                <TextField label="IdToLoad" id="IdToLoad" ref={el => this.IdToLoad = el}/>
                {this.state.currentId == null &&
                    <Form>
                        <Panel>
                            <Panel horizontal>
                                <Button label="Save" submit id="SubmitUpdate" />
                            <Button label="New" id="New" onClick={this.addNewHandler}/>
                            </Panel>
                        </Panel>
                        <Panel >
                            <TextField label="Id" id="Id" />
                            <TextField label="Description" id="Descrip" />
                            <TextField label="Observations" id="Observations" />
                        </Panel>
                    </Form>
                }
            </VMContext>
        );
    }
};
export default withTheme(TestForm);

If you put an idToLoad greater than 0 it works properly, and if you put and idToLoad equal to 0 it also works properly loading default value. However if you click the add button it doesn't work.
What am I doing wrong?

Thank you

Couldn't reproduce the async issue. Tested with the same code you pasted here. After 2 seconds, the fields were populated correctly.

Can you check the last code you pasted. Your description does not match the code's behavior.

You are right last code is not correct. After review it I solved the issue so forget about that.
Related to the async issue I can reproduce any time I want. So, maybe I didn't explained very well how to reproduce the problem. So, I have recorded the following video https://screencast-o-matic.com/watch/cFlh0urwgN There I show you first how it fails with async code and how it works with sync code.

This is the code I use for the video:
View:

import PropTypes from 'prop-types';
import dotnetify from 'dotnetify';
import { Scope } from 'dotnetify';
import { withTheme, VMContext, Form, Element, Panel, TextField, Button  } from 'dotnetify-elements';

dotnetify.debug = true

class TestForm extends React.Component {
    state = { currentId: null };
    addNewHandler = _ => {
        this.IdToLoad.dispatch("");
    }

    render() {
        return (
            <VMContext vm="TestVM" onStateChange={state => this.setState({ currentId: state.IdToLoad })} >
                <TextField label="IdToLoad" id="IdToLoad" ref={el => this.IdToLoad = el}/>
                {this.state.currentId !== null &&
                    <Form>
                        <Panel>
                            <Panel horizontal>
                                <Button label="Save" submit id="SubmitUpdate" />
                            <Button label="New" id="New" onClick={this.addNewHandler}/>
                            </Panel>
                        </Panel>
                        <Panel >
                            <TextField label="Id" id="Id" />
                            <TextField label="Description" id="Descrip" />
                            <TextField label="Observations" id="Observations" />
                        </Panel>
                    </Form>
                }
            </VMContext>
        );
    }
};
export default withTheme(TestForm);

ViewModel:

public class TestVM : BaseVM
    {
        TestDTO myTestDto = new TestDTO() { Id = 2, Descrip = "Test", Observations = "Observa Test" };
        private readonly IServiceProvider _serviceProvider;

        public TestVM(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
            
            var id = AddProperty<int>("Id");
            var descrip = AddProperty<string>("Descrip");
            var observations = AddProperty<string>("Observations");
            id.Value = 0;
            descrip.Value = "";
            observations.Value = "";

            //ASYNC CALL THROWS EXCEPTION: "Why Id is not initialized?"
            var idToLoad = AddProperty<int?>("IdToLoad");
            idToLoad.WithServerValidation((i) => true, "Error");
            idToLoad.Subscribe(async i =>
            {
                if (i == default(int?))
                {
                    id.Value = 0;
                    descrip.Value = "";
                    observations.Value = "";
                    return;
                }
                await Task.Run(() =>
                {
                    Thread.Sleep(2000);
                    return "Async work complete";
                });
                id.Value = myTestDto.Id;
                descrip.Value = myTestDto.Descrip;
                observations.Value = myTestDto.Observations;
                PushUpdates();
            });

            // //SYNC CALL WORKS PROPERLY
            // var idToLoad = AddProperty<int?>("IdToLoad");
            // idToLoad.WithServerValidation((i) => true, "Error");
            // idToLoad.Subscribe(i =>
            //{
            //    if (i == default(int?))
            //    {
            //        id.Value = 0;
            //        descrip.Value = "";
            //        observations.Value = "";
            //        return;
            //    }
            //    Task.Run(() =>
            //    {
            //        Thread.Sleep(2000);
            //        return "Async work complete";
            //    }).GetAwaiter().GetResult();
            //    id.Value = myTestDto.Id;
            //    descrip.Value = myTestDto.Descrip;
            //    observations.Value = myTestDto.Observations;
            //});

            AddProperty<TestDTO>("SubmitUpdate")
                .Subscribe(formData =>
                {
                    if (formData.Id == 0)
                        throw new Exception("Why Id is not initialized?");
                });
        }

    }

Model:

public class TestDTO
    {
        public int Id { get; set; }
        public string Descrip { get; set; }
        public string Observations { get; set; }
    }

OK, I understand now what you were saying. The Form element only retained the initial values of zero and empty strings when the element was first rendered, and you were expecting the asynchronous PushUpdates to update those initial values, but it didn't, due to the design of the Form as I explained earlier in the thread.

I will reconsider the design to accommodate this use case, but I think there is one thing you can immediately do to resolve this. So instead of using IdToLoad to initialize local state currentId, switch it to use the Id, so that the Form will only be rendered after the async process initializes the values.

Problem of using Id instead of IdToLoad will be that if the user wants to change the id on a TestDTO it won't be possible cause that will try to load another TestDTO with the new id. That's why it is in a separate field.
By the moment I can manage the issue using synchronous code

Ah ok I got it using Id for initializing currentId it works asynchronously.

And yes you are right I was expecting PushUpdates to update initial values of the form. And that's why I have problems if I load a TestDTO first and a second one with different data after that one. If I update the second one all the non edited fields have the values of the first loaded TestDTO.

Now I see crearly Form element is intended to be used for browsing through collections of items or for editing one item but not one form for both uses.
Thank you for your patience.

When you said "I will reconsider the design to accommodate this use case,.." do you mean that in short term you will provide a work mode for forms that allow the view model to control when the PushUpdates should update initial values of the form?
If the answer is yes, do you have any estimation of when you will work on that feature?
Thnak you

I'm not sure if I should change the behavior, especially when this has been solved by controlling the render time of the Form as we've discussed in this thread, which is actually a better solution - the components should only render when it has data to display.

Finally I solved it using a var called Loading, when a new id is requested it takes true value. When data is retrieved from db and assigned to the viewmodel it takes false value.
On the view the form only is shown when Loading is false. This resets form on every load and it works properly.
Thank you