Johnathon Sullinger Johnathon Sullinger - 3 months ago 11
Ajax Question

Update main view after ajax rendered a partial view

I have a list of "apps" that users can run. Each app targets a specific API of ours to demonstrate what the API does. Some of these apps require user input so that I can pass user-given parameters into the API.

Each app is responsible for generating the HTML that represents its output. For Apps that do not require any input, it's a straight forward process of executing them in a controller/action from an ajax request, and updating the view with the output.

The challenge is wiring up user input support. I've managed to get 90% of the way there and have hit a roadblock. The apps are responsible for instantiating their own view model. Using a bit of convention, each App has an associated partial view that is located under the same path that the app's namespace is. This lets me create a view model for the app, and return the partial view for each app like this:

public ActionResult GetViewModel(string appId)
{
IApp app = AppFactory.GetAppById(appId);
string path = app.GetType().FullName.Replace('.', '/');
return PartialView($"~/Views/{path}.cshtml", app.CreateViewModel());
}


An example partial view, using an app supplied view model, looks like this:

@using Examples.DataAccess.Query;
@model Query_02_ParameterizedQueryViewModel

@using (@Html.BeginForm("RunAppFromViewModel", "Home", FormMethod.Post))
{
@Html.ValidationSummary(true)
<fieldset>
<div class="form-inline">
<div class="form-group">
@Html.LabelFor(viewModel => viewModel.City)
@Html.EditorFor(viewModel => viewModel.City, new { placeholder = "Phoenix" })
@Html.ValidationMessageFor(viewModel => viewModel.City)
@Html.HiddenFor(viewModel => viewModel.AppId)
</div>
</div>

<button class="btn btn-default" type="submit">Run</button>
</fieldset>
}


The main view has a button that opens a modal bootstrap dialog. When the dialog is opened, I make an ajax request to the server to get the view model and partial view. I then insert the partial view into the modal dialog and update the client-side validation so it works with the unobtrustive stuff. The problem however is that when the form is posted back to the server, and the outputted HTML from the app is returned from the server to the client, I dont know how to update the main view with it.

For example, this is the Main view and the JavaScript that handles both the View Model based Apps and the non-VM based apps.

@using Examples.Browser.ViewModels;
@using Examples.Browser.Models;

@{
ViewBag.Title = "Home Page";
}

@model List<ApiAppsViewModel>

<div class="jumbotron">
<h1>Framework API Micro-Apps</h1>
<p class="lead">Micro-apps provide a way to execute the available APIs in the Framework, without having to write any code, and see the results.</p>
</div>

<div class="container">
<h3 class="text-center">Available API Apps</h3>
<div class="table-responsive">
<table class="table table-hover table-responsive">
<tr>
<th>@nameof(ApiApp.Components)</th>
<th>@nameof(ApiApp.Name)</th>
<th>@nameof(ApiApp.Description)</th>
<th>Options</th>
</tr>

@foreach (ApiAppsViewModel app in this.Model)
{
<tr>
<td>@string.Join(",", app.Components)</td>
<td>@app.Name</td>
<td>@app.Description</td>
<td>
@if (app.IsViewModelRequired)
{
<button type="button"
data-app="@app.Id.ToString()"
data-vm-required="@app.IsViewModelRequired"
data-app-name="@app.Name"
data-toggle="modal"
data-target="#appParameters"
class="btn btn-success">
Run App
</button>
}
else
{
<button type="button"
data-app="@app.Id.ToString()"
data-vm-required="@app.IsViewModelRequired"
class="btn btn-success">
Run App
</button>
}
</td>
</tr>
<tr class="hidden">
<td colspan="4">
<div class="container alert alert-info" data-app="@app.Id.ToString()">

</div>
</td>
</tr>
}
</table>
</div>
</div>


<div class="modal fade"
id="appParameters"
role="dialog"
aria-labelledby="appParametersLabel">
<div class="modal-dialog"
role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="appParametersLabel"></h4>
</div>

<div class="modal-body" id="appDialogBody">
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>

<script type="text/javascript">
$('.btn-success').click(function () {
var button = $(this);
var appId = $(this).data("app");
var vmRequired = $(this).data("vm-required");

if (vmRequired == "False") {
var url = "/Home/RunApp?appId=" + appId;
$.get(url, function (data) {
$("div[data-app='" + appId + "']").html(data);
var buttonColumn = button.parent();
var appRow = buttonColumn.parent();
var hiddenRow = appRow.next()
hiddenRow.removeClass("hidden");

appRow.click(function () {
var hiddenColumn = hiddenRow.children().first();
var resultsDiv = hiddenColumn.children().first();
resultsDiv.empty();
hiddenRow.addClass("hidden");
$(this).off();
hiddenRow.off();
})
hiddenRow.click(function () {
var hiddenColumn = $(this).children().first();
var resultsDiv = hiddenColumn.children().first();
resultsDiv.empty();
$(this).addClass("hidden");
appRow.off();
$(this).off();
})
});
} else {
var appName = $(this).data("app-name");
$('#appParametersLabel').html(appName);
var url = "/Home/GetViewModel?appId=" + appId;
$.get(url, function (data) {

$('#appDialogBody').html(data);
var dialog = $('#appDialogBody');
$.validator.unobtrusive.parse(dialog);
});
$('#appParameters').modal({
keyboard: true,
backdrop: "static",
show: false,

}).on('show', function () {
});
}
});
</script>


When there isn't a view model needed, I stuff the results in an invisible row and make the row visible. Since the View Model apps have their form data submitted from a partial view, when I return the HTML from the controller, it renders it out as raw text to the browser. I assume I can write some java script to handle this but i'm not sure what that would look like. How do I get the form post from the partial view, to return the HTML it generates back to the invisible row within the main view?

This is the controller action that the form posts to, and returns, along with the controller action non-view model based apps use to run their apps and generate the HTML.

[HttpGet]
public async Task<string> RunApp(string appId)
{
IApp app = AppFactory.GetAppById(appId);
if (app == null)
{
return "failed to locate the app.";
}

IAppOutput output = await app.Run();
if (output == null)
{
return "Failed to locate the app.";
}

return output.GetOutput();
}

[HttpPost]
public async Task<string> RunAppFromViewModel(FormCollection viewModelData)
{
IApp app = AppFactory.GetAppById(viewModelData["AppId"]);
foreach(PropertyInfo property in TypePool.GetPropertiesForType(app.ViewModel.GetType()))
{
property.SetValue(app.ViewModel, viewModelData[property.Name]);
}

IAppOutput output = await app.Run();
return output.GetOutput();
}

Answer

If you want to update the existing page with the data returned by the RunAppFromViewModel() method, then you need to submit your form using ajax. Since the form is loaded dynamically after the initial page has been loaded, you need to use event delegation. You will also need to store the element you want updated when the form is loaded.

var hiddenRow;
$('.btn-success').click(function () {
    // store the element to be updated
    hiddenRow = $(this).closest('tr').next('tr').find('.container');
    ....
});

// Handle the submit event of the form
$('#appDialogBody').on('submit', 'form', function() {
    // check if the form is valid
    if (!$(this).valid())
    {
        return;
    }
    var formData = $(this).serialize(); // serialize the forms controls
    var url = '@Url.Action("RunAppFromViewModel", "Home");
    $.post(url, formData , function(response) {
        hiddenRow.html(response); // assumes your returning html
    });
    return false; // cancel the default submit
});