7

I have a page with server side rendering using razor, where you can add a couple of elements from different lists, fill some fields and create a request from it on submit.

Each time an item is added/taken from any list, I send a post with submit button to a specific action, e.g. "CustomerSelected". I do this, because I need to recreate additional view components for the added item. In these methods I would like to add added objects to the db context, so on submit I can just say SaveChanges() and not have to assemble everything in the same method. But in .net core db context is per request and it is advisable to keep it that way. In this case how can I store these temporary entity objects between requests so later if someone decides to submit them I can say SaveChanges() or discard them otherwise?

I would like to have something like this:

public IActionResult CustomerAdded(int customerId)
{
    var customer = _context.Customers.First(c => c.IdCustomer == customerId);
    var message = _context.Messages.First(m => m.IdMessage = _idMessage);
    message.Customers.Add(customer);
    return View();
}

public IActionResult ItemAdded(int itemId)
{
    var item = _context.Items.First(c => c.IdItem == itemId);
    var message = _context.Messages.First(m => m.IdMessage = _idMessage);
    message.Items.Add(item);
    return View();
}

public IActionResult Submit()
{
    _context.SaveChanges();
    return View();
}

If this is not possible then I was thinking about adding individual elements in each method and save them there and onsubmit I would build the last final element. But if someone closes their browser without submitting then I have incomplete data laying in my database. I would have to run some kind of job to delete those and it seems to be too much for such a simple task.

Tetsuya Yamamoto
  • 24,297
  • 8
  • 39
  • 61
FCin
  • 3,804
  • 4
  • 20
  • 49
  • Can you try it making those post methods void and prevent returning anything ? use ajax to run that method so it won't change page. and when user clicks at submit SaveChanges and return to your page. ? – Halil İbrahim Dec 28 '18 at 09:48
  • Have you tries this please: `services.AddDbContext(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")),ServiceLifetime.Singleton); ` – TanvirArjel Dec 28 '18 at 09:56
  • @TanvirArjel I don't want to set dbcontext to a singleton – FCin Dec 28 '18 at 10:00
  • @FCin Better solution you can stored the data on cache and retrieve on the submit method? After successful save changes remove the cache. – TanvirArjel Dec 28 '18 at 10:04
  • @TanvirArjel Not sure what you mean exactly. Keep it in session? I don't want to assemble all entities in one method, but add them in their designated methods, e.g. add customer in "CustomerChanged" method. – FCin Dec 28 '18 at 10:09
  • 1
    @FCin I have understood your requirement! Session and Cache is not the same thing! If you need any help you can share you code with the remote access like Team Viewer Visual Studio Live share. – TanvirArjel Dec 28 '18 at 10:11
  • @FCin you don't *store* data in a DbContext, you use the context to communicate with the database. The DbContext can *cache* data during its lifetime but it's *never* meant to be a cross-request cache or session state. That's why ASP.NET provides separate services for [caching](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/memory?view=aspnetcore-2.2) and [session state](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/app-state?view=aspnetcore-2.2#session-state) – Panagiotis Kanavos Jan 03 '19 at 09:58
  • @PanagiotisKanavos So I assume that keeping viewmodel inside session and retriving it in each action method is a standard practice? – FCin Jan 03 '19 at 10:02
  • @FCin no, it means you are talking about something different. If those entities are valid only for a single page, you are asking about a single *form's* state. If those temporary entities are used by different pages, they aren't temporary after all. In-memory storage may *not* be appropriate for either - what would happen if you have *two* or more web servers? – Panagiotis Kanavos Jan 03 '19 at 10:06
  • @PanagiotisKanavos These entities are only valid for a single page. I'm asking, because right now I keep viewmodel in session and assemble all entities in `Submit` and add them to database. I wonder if I can add each element of the whole entity in each separate action, so I don't have to do everything inside one method, but on the other hand I don't want to keep unfinished/partial data in database. – FCin Jan 03 '19 at 10:10
  • @FCin in an SPA you would store a form's state as objects in the browser. On the server side, you could store them in the Session or store them in a separate store like Redis. You'll have to consider what happens in a farm scenario - you can't use an in-memory cache or the DbContext in this case. You'll also need to think what happens if the user takes too long while working with one such entity. Should it survice a session expiration? – Panagiotis Kanavos Jan 03 '19 at 10:11
  • @FCin why *not* keep draft data in the database or a different store? What happens if the user takes a coffee break and the session expires, are the entities lost? What if they close the browser and open it again? Some business processes have explicit draft and incomplete phases – Panagiotis Kanavos Jan 03 '19 at 10:13
  • @PanagiotisKanavos I know how to do this in SPA, unfortunately I have to work with server side. That's why I would like to store everything in database and add it to database as soon as user selects it. Then it would be easy to return whole page on each post request. But how do I remove unfinished entities, e.g. user selects a couple of things from database and closes browser. How do I clean my database from these unsubmitted entries? This is a very small website, I don't want to create database jobs. – FCin Jan 03 '19 at 10:15
  • @FCin in any case, with a form's state you can't depend on the context or in-memory cache, not if you want to use load balancing. The quick&dirty solution is to use the Session as a cache, and configure the session storage mechanism to use the database or Redis in a farm scenario. The `dirty` part is that big objects take a lot of memory which means there's less memory to serve requests in a high-traffic scenario – Panagiotis Kanavos Jan 03 '19 at 10:16
  • @FCin try the quick&dirty approach then, but be aware of the limitations. – Panagiotis Kanavos Jan 03 '19 at 10:18
  • @PanagiotisKanavos What would be the more sophisticated way? – FCin Jan 03 '19 at 10:21
  • @FCin depends on the scenario! Sophisticated, as in something that works across 70 front-end web servers on AWS, like an online travel agency? Explicit storage in Redis is nice. For a single server internal web application, buying extra RAM and using session storage may be quite enough. – Panagiotis Kanavos Jan 03 '19 at 10:25
  • Do you absolutely need to store **server side** between requests? You could use the HTML5 sessionstorage or localstorage. – Adam Vincent Jan 03 '19 at 13:39
  • DbContext per request is not specific to .NET Core or EF Core. If you used a singleton DbContext before in a web context, your application was broken. See [One DbContext per web request… why?](https://stackoverflow.com/questions/10585478/one-dbcontext-per-web-request-why) – user247702 Jan 04 '19 at 10:14
  • @AdamVincent I have one page with about 50 fields and a dual list. Each time user selects item from dual list it needs to render additional viewcomponents for this item in different places on the website. I would have to serialize 50 fields in json and pass them with ajax. A lot of javascript code I don't want. – FCin Jan 04 '19 at 10:44
  • What is `_idMessage` and how is it populated? And what is the relationship between `Message` and `Customer`/`Item`? And which entities do you want to cache? From you information so far I wonder f you need to cache any entities at all. – Gert Arnold Jan 06 '19 at 15:05

3 Answers3

5

It's not good idea to use server resources to track changes in such scenarios. In scenarios like shopping basket, list or batch editing it's better track changes at client-side.

Your requirement to get Views generated at server-side doesn't mean you need to track changes in DbContext. Get the index view and create view from server, but track changes on client. Then to save, post all data to the server to save changes based on the tracking info that you have.

The mechanism for client-side change tracking depends to the requirement and the scenario, for example you can track changes using html inputs, you can track changes using cookie, you can track changes using javascript objects in browser memory like angular scenarios.

Here is this post I'll show an example using html inputs and model binding. To learn more about this topic, take a look at this article by Phill Haack: Model Binding To A List.

Example

In the following example I describe a list editing scenario for a list of customers. To make it simple, I suppose:

  • You have a list of customers which you are going to edit at client. You may want to add, edit or delete items.
  • When adding new item, the row template for new row should come from server.
  • When deleting, you mark an item as deleted by clicking on a checkbox on the row.
  • When adding/editing you want to show validation errors near the cells.
  • You want to save changes at the end, by click on Save button.

To implement above scenario Then you need to create following models, actions and views:

Trackable<T> Model

This class is a model which helps us in client side tracking and list editing:

public class Trackable<T>
{
    public Trackable() { }
    public Trackable(T model) { Model = model; }
    public Guid Index { get; set; } = Guid.NewGuid();
    public bool Deleted { get; set; }
    public bool Added { get; set; }
    public T Model { get; set; }
}

Customer Model

The customer model:

public class Customer
{
    [Display(Name ="Id")]
    public int Id { get; set; }

    [StringLength(20, MinimumLength = 1)]
    [Required]
    [Display(Name ="First Name")]
    public string FirstName { get; set; }

    [StringLength(20, MinimumLength = 1)]
    [Required]
    [Display(Name ="Last Name")]
    public string LastName { get; set; }

    [EmailAddress]
    [Required]
    [Display(Name ="Email Name")]
    public string Email { get; set; }
}

Index.cshtml View

The Index view is responsible to render List<Trackable<Customer>>. When rendering each record, we use RowTemplate view. The same view which we use when adding new item.

In this view, we have a submit button for save and a button for adding new rows which calls Create action using ajax.

Here is Index view:

@model IEnumerable<Trackable<Customer>>
<h2>Index</h2>
<form method="post" action="Index">
    <p>
        <button id="create">New Customer</button>
        <input type="submit" value="Save All">
    </p>
    <table class="table" id="data">
        <thead>
            <tr>
                <th>
                    Delete
                </th>
                <th>
                    @Html.DisplayNameFor(x => x.Model.FirstName)
                </th>
                <th>
                    @Html.DisplayNameFor(x => x.Model.LastName)
                </th>
                <th>
                    @Html.DisplayNameFor(x => x.Model.Email)
                </th>
            </tr>
        </thead>
        <tbody>
            @foreach (var item in Model)
            {
                await Html.RenderPartialAsync("RowTemplate", item);
            }
        </tbody>
    </table>
</form>

@section Scripts{
    <script>
        $(function () {
            $('#create').click(function (e) {
                e.preventDefault();
                $.ajax({
                    url: 'Create',
                    method: 'Get',
                    success: function (data) {
                        $('#data tbody tr:last-child').after(data);
                    },
                    error: function (e) { alert(e); }
                });
            });
        });
    </script>
}

RowTemplate.cshtml View

This view is responsible to render a customer record. In this view, we first render the Index in a hidden, then set a prefix [index] for the fields and then render the fields, including index again, added, deleted and model id:

Here is RowTemplate View:

@model Trackable<Customer>
<tr>
    <td>
        @Html.HiddenFor(x => x.Index)
        @{Html.ViewData.TemplateInfo.HtmlFieldPrefix = $"[{Model.Index}]";}
        @Html.HiddenFor(x => x.Index)
        @Html.HiddenFor(x => x.Model.Id)
        @Html.HiddenFor(x => x.Added)
        @Html.CheckBoxFor(x => x.Deleted)
    </td>
    <td>
        @Html.EditorFor(x => x.Model.FirstName)
        @Html.ValidationMessageFor(x => x.Model.FirstName)
    </td>
    <td>
        @Html.EditorFor(x => x.Model.LastName)
        @Html.ValidationMessageFor(x => x.Model.LastName)
    </td>
    <td>
        @Html.EditorFor(x => x.Model.Email)
        @Html.ValidationMessageFor(x => x.Model.Email)
    </td>
</tr>

CustomerController

public class CustomerController : Controller
{
    private static List<Customer> list;
}

It will have the following actions.

[GET] Index Action

In this action you can load data from database and shape it to a List<Trackable<Customer>> and pass to the Index View:

[HttpGet]
public IActionResult Index()
{
    if (list == null)
    {
        list = Enumerable.Range(1, 5).Select(x => new Customer()
        {
            Id = x,
            FirstName = $"A{x}",
            LastName = $"B{x}",
            Email = $"A{x}@B{x}.com"
        }).ToList();
    }
    var model = list.Select(x => new Trackable<Customer>(x)).ToList();
    return View(model);
}

[GET] Create Action

This action is responsible to returning new row template. It will be called by a button in Index View using ajax:

[HttpGet]
public IActionResult Create()
{
    var model = new Trackable<Customer>(new Customer()) { Added = true };
    return PartialView("RowTemplate", model);
}

[POST] Index Action

This action is responsible for receiving the tracked item from client and save them. The model which it receives is List<Trackable<Customer>>. It first strips the validation error messages for deleted rows. Then removes those which are both deleted and added. Then checks if model state is valid, tries to apply changes on data source.

Items having Deleted property as true are deleted, items having Added as true and Deleted as false are new items, and rest of items are edited. Then without needing to load all items from database, just using a for loop, call db.Entry for each item and set their states and finally save changes.

[HttpPost]
public IActionResult Index(List<Trackable<Customer>> model)
{
    //Cleanup model errors for deleted rows
    var deletedIndexes = model.
        Where(x => x.Deleted).Select(x => $"[{x.Index}]");
    var modelStateDeletedKeys = ModelState.Keys.
        Where(x => deletedIndexes.Any(d => x.StartsWith(d)));
    modelStateDeletedKeys.ToList().ForEach(x => ModelState.Remove(x));

    //Removing rows which are added and deleted
    model.RemoveAll(x => x.Deleted && x.Added);

    //If model state is not valid, return view
    if (!ModelState.IsValid)
        return View(model);

    //Deleted rows
    model.Where(x => x.Deleted && !x.Added).ToList().ForEach(x =>
    {
        var i = list.FindIndex(c => c.Id == x.Model.Id);
        if (i >= 0)
            list.RemoveAt(i);
    });

    //Added rows
    model.Where(x => !x.Deleted && x.Added).ToList().ForEach(x =>
    {
        list.Add(x.Model);
    });

    //Edited rows
    model.Where(x => !x.Deleted && !x.Added).ToList().ForEach(x =>
    {
        var i = list.FindIndex(c => c.Id == x.Model.Id);
        if (i >= 0)
            list[i] = x.Model;
    });

    //Reditect to action index
    return RedirectToAction("Index");
}
Reza Aghaei
  • 120,393
  • 18
  • 203
  • 398
0

What about dynamic form(s) with javascript and using type="hidden" or visibility and then sending everything at once

Or using TempData with redirects and reusing that data in other views(form) as input type="hidden"

Flow:

Form1 ->

Controller's Method saves data in TempData and Redirects to Form2 View / Or ViewData and return Form2 View? ->

Form2 has TempData inserted into the form under hidden inputs ->

Submit both at once

Joelty
  • 1,751
  • 5
  • 22
  • 64
-1

Cookie !

public class HomeController : Controller
{

public string Index()
{

    HttpCookie cookie = Request.Cookies["message"];
    Message message = null;
    string json = "";

    if (cookie == null)
    {
        message = new Message();
        json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(message);
        cookie = new HttpCookie("message", json);
    }
    Response.Cookies.Add(cookie);
    return json;
}

public string CustomerAdded(int id)
{
    HttpCookie cookie = Request.Cookies["message"];
    Message message = null;
    string json = "";

    if (cookie == null || string.IsNullOrEmpty(cookie.Value))
    {
        message = new Message();
        json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(message);
        cookie = new HttpCookie("message", json);
    }
    else
    {
        json = cookie.Value;
        message = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<Message>(json);
    }

    if (message.Customers == null) message.Customers = new List<int>();
    if (message.Items == null) message.Items = new List<int>();

    if (!message.Customers.Contains(id))
    {
        message.Customers.Add(id);
    }


    json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(message);
    cookie = new HttpCookie("message", json);

    Response.Cookies.Add(cookie);

    return json;
}


public string ItemAdded(int id)
{
    HttpCookie cookie = Request.Cookies["message"];
    Message message = null;
    string json = "";

    if (cookie == null || string.IsNullOrEmpty(cookie.Value))
    {
        message = new Message();
        json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(message);
        cookie = new HttpCookie("message", json);
    }
    else
    {
        json = cookie.Value;
        message = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<Message>(json);
    }
    if (message.Customers == null) message.Customers = new List<int>();
    if (message.Items == null) message.Items = new List<int>();

    if (!message.Items.Contains(id))
    {
        message.Items.Add(id);
    }

    json = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(message);
    cookie = new HttpCookie("message", json);

    Response.Cookies.Add(cookie);

    return json;
}

public string Submit()
{
    HttpCookie cookie = Request.Cookies["message"];
    Message message = null;
    string json = "";

    if (cookie == null || string.IsNullOrEmpty(cookie.Value))
    {
        return "no data";
    }
    else
    {
        json = cookie.Value;
        message = new System.Web.Script.Serialization.JavaScriptSerializer().Deserialize<Message>(json);
    }

    Response.Cookies["message"].Value = "";
    Response.Cookies["message"].Expires = DateTime.Now.AddDays(-1);

    return "Submited";

}
}

Example links

enter image description here enter image description here enter image description here enter image description here

Mohamed Elrashid
  • 8,125
  • 6
  • 31
  • 46