Mar
27
Written by:
Michael Washington
3/27/2016 10:59 AM
Migrating From LightSwitch
If you are ready to move from Visual Studio LightSwitch to the latest web technologies, the example in this article may provide a useful example. This application recreates the Online Ordering System (An End-To-End LightSwitch Example) that has been used on this site for years.
The key technologies that will be covered are:
- AngularJs- The most popular JavaScript framework for building modern web applications.
- OData 4 – This is used to provide server-side data endpoints that are called by the AngularJs code. This has more features than OData 3 that is in LightSwitch. OData 4 provides custom methods. See the CurrentUser and OrderDetailsForOrder methods in this example.
- TypeScript – This allows you to write JavaScript code that has strong typing and compile-time checking and validation.
- Bootstrap – Like JQuery mobile in LightSwitch, this allows you to create a UI that scales from desktop to mobile with minimal coding.
Online Example
You can try out the live online application at: https://endtoendangular1.lightswitchhelpwebsite.com/ (you will have to create a new account and log in to use it).
This application recreates the Online Ordering System (An End-To-End LightSwitch Example) that has been used on the LightSwitchHelpWebsite.com for years.
The sample application has the following features:
- Products
- Add Products
- Edit Products
- Delete Products
- Orders
- Add Orders
- Edit Orders
- Add Order Details
- Edit Order Details
- Delete Order Details
- Delete Orders
- Business Rules
- Allow the current user to only see their own Orders
- Administrator(s) can see and edit all Orders
Walk-Thru
Orders are displayed in a pageable grid.
They can be edited and deleted.
Use the Add New Order button to create a new Order.
When adding or editing an Order, an Order date can be set, and the details added, by clicking the Add Order Detail button.
Order Details can also be edited or deleted.
When adding or editing an Order Detail, the Product and Quantity is selected using the Edit Order Detail dialog.
The Products are edited using a standard MVC page that is only accessible to Administrators.
Building The Application
Using Visual Studio 2015, we start with the application created in Creating User and Roles Administration Pages for an MVC5 Application.
This provides the user registration, and the roles and management the application requires, as well as creating the database and the database connection.
We then add Code First Entity Framework Migrations (you can get more information on how this works at: http://www.entityframeworktutorial.net/code-first/automated-migration-in-code-first.aspx).
Note: Migrations can be a bit unwieldy to manage so I normally use Entity Framework Designer as shown in the example here: http://openlightgroup.com/Blog/TabId/58/PostId/188/OData4Sample.aspx. This article is a MUST read: Entity Framework Code First Migrations).
For this project, because we do have migrations enabled, we can create a DAL (Data Access Layer) class:
public class EndToEndDAL : DbContext
{
public EndToEndDAL()
: base("name=DefaultConnection")
{
Database.SetInitializer(new MigrateDatabaseToLatestVersion<EndToEndDAL,
EndToEnd_Test.Migrations.Configuration>("DefaultConnection"));
}
// Add a DbSet for each entity type that you want to include in your model. For more information
// on configuring and using a Code First model, see http://go.microsoft.com/fwlink/?LinkId=390109.
public virtual DbSet<Product> Products { get; set; }
public virtual DbSet<Order> Orders { get; set; }
public virtual DbSet<OrderDetail> OrderDetails { get; set; }
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}
public class Product
{
public int Id { get; set; }
[Required]
[MaxLength(255)]
public string ProductName { get; set; }
[Column(TypeName = "Money")]
public decimal ProductPrice { get; set; }
}
public class Order
{
public int Id { get; set; }
[Required]
[MaxLength(255)]
public string UserName { get; set; }
[Required]
[Column(TypeName = "Date")]
public DateTime OrderDate { get; set; }
// Navigation property
public virtual ICollection<OrderDetail> OrderDetails { get; set; }
}
public class OrderDetail
{
public int Id { get; set; }
[Required]
public int Quantity { get; set; }
// Foreign key
[Required]
public int OrderId { get; set; }
// Navigation property
[Required]
public virtual Product Product { get; set; }
}
When the project is run and hits a piece of code that connects to the database, the migrations run, and the database schema is automatically created (or if it needs to be, updated):
The Products Administration
The administration for the Products table is composed of pure MVC pages. This is how they were created.
First, we right-clicked on the Controllers folder and selected Add then New Scaffolded item…
Next, we selected MVC 5 Controller with views, using Entity Framework.
Finally, we select Product as the Model, EndToEndDAL as the Data context, and name the Controller ProductsController and press Add.
The Products View and Controller are automatically created.
We then decorate the methods in the Controller with [Authorize(Roles = "Administrator")] so that only Administrators can alter the table (users will read from the table through the OData methods that we will add later).
We then open the _Layout.cshtml view and add an Administrator's only link to the Products view.
More Set-up
We also need to install AngularJs and OData 4.
We go to the NuGet package Manager Console …
… click to the right of the PM> symbol and paste the entire list following list:
Install-Package Angularjs
Install-Package Microsoft.OData.Client
Install-Package Microsoft.OData.Core
Install-Package Microsoft.OData.Edm
Install-Package Microsoft.Spatial
Install-Package Microsoft.AspNet.OData
Install-Package Microsoft.AspNet.WebApi.WebHost
It will take some time, but it will install all the packages.
(note: you will usually have to press return when it gets to the last package to install it).
We update the WebApiConfig.cs file to the following to enable OData:
using EndToEnd_Test.Models;
using Microsoft.OData.Edm;
using System.Web.Http;
using System.Web.OData.Builder;
using System.Web.OData.Extensions;
namespace EndToEnd_Test
{
class WebApiConfig
{
#region Register
public static void Register(HttpConfiguration config)
{
// OData routes
// These must be configured before the WebAPI routes
config.MapHttpAttributeRoutes();
config.MapODataServiceRoute(
routeName: "ODataRoute",
routePrefix: "odata",
model: GenerateEntityDataModel());
// Web API routes
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
}
#endregion
#region GenerateEntityDataModel
private static IEdmModel GenerateEntityDataModel()
{
ODataModelBuilder builder = new ODataConventionModelBuilder();
// ** The OData controllers will be configured here
return builder.GetEdmModel();
}
#endregion
}
}
We also update the Global.asax file to ensure that WebApiConfig is the first item in the Application_Start() method.
Basically OData has to be configured first or it wont work.
Create DTO Classes
In the Models folder, create a file called DTOoData.cs using the following code:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace EndToEnd_Test.Models
{
public class DTOProduct
{
[Key]
public int Id { get; set; }
public string ProductName { get; set; }
public string ProductPrice { get; set; }
}
public class DTOOrder
{
[Key]
public int Id { get; set; }
public string UserName { get; set; }
public string OrderDate { get; set; }
public ICollection<DTOOrderDetail> colOrderDetails { get; set; }
}
public class DTOOrderDetail
{
[Key]
public int Id { get; set; }
public int Quantity { get; set; }
public int ProductId { get; set; }
}
}
We prepend DTO in front of the class name to indicate that each is a Data Transfer Object. Meaning, these classes will be used to transfer data between the AngularJs TypeScript code and the server-side OData methods.
You will always want to specify a [Key] for each class and always populate it with a unique value when using it.
Make sure we have downloaded and installed the latest TypeScript for Visual Studio from:
http://www.typescriptlang.org/#Download
Next, we add a TypeScript folder and a sub-folder called References.
In the References folder, we add the following TypeScript definition files:
- angular.d.ts
- bootstrap.d.ts
- jquery.d.ts
(you can download the files from DefinitelyTyped)
These files are required to allow TypeScript to provide compile-time validation of our custom code that calls methods from these libraries.
Now, Right-click on the TypeScript directory and select Add then TypeScript File.
Name the file oDataApp and use the following code for the file:
module ODataApplication {
export class ODataApp {
static oDataAppModule: ng.IModule =
angular.module('ODataApp', ['ngAnimate', 'ui.bootstrap']);
}
}
This code declares the AngularJs application, called ODataApp, and injects ngAnimate and ui-bootstrap dependencies into the application.
Next, make a TypeScript file called oDataController.ts and use the following code:
module ODataApplication {
//#region classes
// These classes allow us to use strong typing
// in our code. We want to avoid typing any
// variable or property as 'any'
class Product {
Id: number;
ProductName: string;
ProductPrice: string;
}
class Order {
Id: number;
UserName: string;
OrderDate: string;
colOrderDetails: Array<OrderDetail>;
}
class OrderDetail {
Id: number;
Quantity: number;
ProductId: number;
Product: Product;
}
class ProductArray {
value: Array<Product>
}
class GetProductArrayResults {
data: ProductArray;
}
class OrderArray {
value: Array<Order>
}
class GetOrderArrayResults {
data: OrderArray;
}
class OrderDetailsArray {
value: Array<OrderDetail>
}
class GetOrderDetailsArrayResults {
data: OrderDetailsArray;
}
class GetOrderResults {
data: Order;
}
//#endregion
var vm: ODataControllerClass;
export class ODataControllerClass {
constructor(private $http: ng.IHttpService) {
// Set a global 'vm' to 'this' so that we have access
// to all properties and methods in the view model
// in all the methods.
vm = this;
// Call the method to make a server-side call to
// get the name of the currrently loogged in user.
this.GetCurrentUser();
// Call the method to make a server-side call to
// get the Products. This will fill a collection
// that the Products dropdown on the Edit Order Details
// page is bound to.
this.GetAllProducts();
// Call the method to make a server-side call to
// get the Orders. This will fill a collection
// that the Orders is bound to.
this.GetAllOrders();
}
//#region Properties
error: string = "";
// We need to base URL for the Odata service calls
baseUrl: string = $("#linkRoot").attr("href");
currentUser: string = "";
colProducts: Array<Product> = new Array<Product>();
colOrders: Array<Order> = new Array<Order>();
selectedOrder: Order = null;
selectedProduct: Product = null;
selectedOrderDetail: OrderDetail = null;
totalOrderCount: number = 0;
currentOrderPage: number = 1;
//#endregion
//#region Failure(error: any)
Failure(error: any) {
// Process any errors
// This method is called whenever there is a failure
if (error.message != undefined) {
vm.error = error.message;
}
else if (error.statusText != undefined) {
vm.error = error.statusText;
if (error.data != undefined) {
if (error.data.error != undefined) {
if (error.data.error.innererror != undefined) {
if (error.data.error.innererror.message != undefined) {
vm.error = vm.error
+ ", "
+ error.data.error.innererror.message;
}
}
}
}
}
else {
vm.error = "Unknown error";
}
// Display the error
alert(vm.error);
}
//#endregion
//#region copyObject<T>(object: T): T
copyObject<T>(object: T): T {
var objectCopy = <T>{};
for (var key in object) {
if (object.hasOwnProperty(key)) {
objectCopy[key] = object[key];
}
}
return objectCopy;
}
//#endregion
}
// This is actually the first thing to run in this module.
// This is placed at the end because the 'ODataControllerClass' (above),
// that is being registered, has to be defined first.
ODataApp.oDataAppModule
.controller("oDataController", ["$http", ODataControllerClass]);
}
This file will contain the primary code for the application. For now it just creates the supporting code.
Notice that we define classes for Product, Order and OrderDetail. We will fill these classes with data when we make calls to the OData methods that will transmit that data using the DTO classes that we created earlier.
Note: An excellent video by Bill Wagner on how to create AngularJs code using TypeScript is available here: Angular Applications with TypeScript.
Also, I created a tutorial here: Step-By-Step OData 4 / TypeScript / AngularJs / CRUD Sample.
Set-Up The Page
All the remaining markup will be contained in the Index.cshtml file that is located in the Home folder under Views.
Note: While we always code in the .ts files (located in the TypeScript directory), we actually reference the .js files in the markup (above). These files contain pure JavaScript and are created by the TypeScript compiler in Visual Studio each time the application is compiled. The .js files are what are used at run-time.
After registering the oDataApp and oDataController files we enable them in the application in the outermost DIV of the page markup using this code:
<div id="home" class="tab-pane fade in active"
data-ng-app="ODataApp"
data-ng-controller="oDataController as vm">
New Order
A good place to start to examine the application is the Add New Order button.
The button is declared using the following code:
<button id="AddNewOrderButton"
type="button"
class="btn btn-primary btn-sm"
data-dismiss="modal"
ng-click="vm.AddNewOrder()">
Add New Order
</button>
The EditOrderModal popup will open when the button is clicked because we also have the following JavaScript on the page:
<!-- JavaScript -->
<script>
$(document).ready(function () {
$('#AddNewOrderButton').on('click', function () {
$('#EditOrderModal').modal('show');
});
});
</script>
The ng-click property in the button markup calls the following AddNewOrder() method in the AngularJs TypeScript Controller:
AddNewOrder() {
// Create an empty Order
vm.selectedOrder = new Order();
// Set Id to 0 so the server side code will
// know this is an insert
vm.selectedOrder.Id = 0;
// Call the CurrentDate() method to get the current date
vm.selectedOrder.OrderDate = vm.CurrentDate();
// Create an empty colOrderDetails so that
// Order Detail records can be appended to it
vm.selectedOrder.colOrderDetails = new Array<OrderDetail>();
// Set the current user. This value was set by the
// call to this.GetCurrentUser() in the constructor
// of the Angular controller class
vm.selectedOrder.UserName = vm.currentUser;
}
This method consumes the name of the Current User, that is retrieved from a call (that we place in the module constructor), to the following CurrentUser() method:
GetCurrentUser() {
// Get the name of the current user
// Construct the path to the OData end point
var urlString: string = vm.baseUrl + "odata/CurrentUser";
var result: any = vm.$http({
url: urlString,
method: "POST"
});
// Process the result of the OData call
// If successful, the Success method will be called
// If not successful the Failure method will be called
result.then(Success, vm.Failure)
function Success(CurrentUser: any) {
// Set the name of the current user
vm.currentUser = CurrentUser.data.value;
}
}
This method calls the OData end-point called CurrentUser.
To implement it, we create a controller file called OData4Controller.cs using the following code:
using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Net;
using System.Web.Http;
using System.Web.OData;
using System.Web.OData.Routing;
using EndToEnd_Test.Models;
namespace EndToEnd_Test.Controllers
{
public class OData4Controller : ODataController
{
private EndToEndDAL db = new EndToEndDAL();
#region public IHttpActionResult CurrentUser()
// odata/CurrentUser()
[ODataRoute("CurrentUser()")]
public IHttpActionResult CurrentUser()
{
return Ok(this.User.Identity.Name);
}
#endregion
// Utility
#region protected override void Dispose(bool disposing)
protected override void Dispose(bool disposing)
{
if (disposing)
{
db.Dispose();
}
base.Dispose(disposing);
}
#endregion
}
}
To enable this OData method, we add the following code to the GenerateEntityDataModel() method in the WebApiConfig.cs file:
// CurrentUser function returns string
var CurrentUserFunction = builder.Function("CurrentUser");
CurrentUserFunction.Returns<string>();
The following diagram illustrates the connection to the various parts:
Edit Order Detail
The markup for the Add Order Detail button is as follows:
<button type="button"
id="EditOrderDetailButton"
title="Edit"
class="btn btn-primary btn-xs glyphicon glyphicon-edit"
onclick="$('#EditOrderDetail').modal('show');"
ng-click="vm.selectedOrderDetail = OrderDetail">
</button>
When you click on the Add Order Detail button, it sets the current OrderDetail as the selectedOrderDetail and the Edit Order Detail popup opens:
The Edit Order Detail popup contains a dropdown of the available Products (it also needs to indicate the selected Product if the popup is opened for an existing record).
The markup for the dropdown is as follows:
<select name="ProductDropdown"
class="form-control"
ng-model="vm.selectedOrderDetail.Product"
ng-options="Product.ProductName for Product in vm.colProducts">
</select>
Note: the ng-model for the dropdown binds the dropdown to the Product property of the selectedOrderDetail (that was set when the Add Order Detail button was clicked).
The ng-options for the dropdown, indicates that the ProductName is to be displayed from the collection contained in the colProducts property (that is composed of an Array of Products) that is filled by the following code in the controller:
GetAllProducts() {
// Construct the path to the OData end point
var urlString: string = vm.baseUrl + "odata/ODataProducts";
// Call the OData end point
var result: ng.IPromise<GetProductArrayResults> = vm.$http({
url: urlString,
method: "GET"
});
// Process the result of the OData call
// If successful, the Success method will be called
// If not successful the Failure method will be called
result.then(Success, vm.Failure)
// Handle a successful call
function Success(results: GetProductArrayResults) {
// Fill the Products collection
vm.colProducts = results.data.value;
}
}
This method calls the OData end-point called ODataProducts.
Note: That while the AngularJs code just makes a call to ODataProducts, the server-side OData method is actually called GetODataProducts. The reason is that when calling an OData queryable method (one that implements IQueryable) you use the word Get in front of the method name.
To enable it, we create a controller file called ODataProductsController.cs using the following code:
using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Net;
using System.Web.Http;
using System.Web.OData;
using System.Web.OData.Routing;
using EndToEnd_Test.Models;
namespace EndToEnd_Test.Controllers
{
public class ODataProductsController : ODataController
{
private EndToEndDAL db = new EndToEndDAL();
#region public IQueryable<DTOProduct> GetODataProducts()
// GET: odata/ODataProducts
[Authorize]
[EnableQuery(PageSize = 100)]
public IQueryable<DTOProduct> GetODataProducts()
{
var result = (from product in db.Products
select new DTOProduct
{
Id = product.Id,
ProductName = product.ProductName,
ProductPrice = product.ProductPrice.ToString()
});
return result.AsQueryable();
}
#endregion
// Utility
#region protected override void Dispose(bool disposing)
protected override void Dispose(bool disposing)
{
if (disposing)
{
db.Dispose();
}
base.Dispose(disposing);
}
#endregion
}
}
Note: We decorate the method by setting the PageSize property of EnableQuery. This is important to not allow a malicious user to manually pass a query that could bring down the database by asking for too many records at once.
To enable this OData method, we add the following code to the GenerateEntityDataModel() method in the WebApiConfig.cs file:
// Register ODataProducts
builder.EntitySet<DTOProduct>("ODataProducts");
Save Order Detail
The following markup is used for the Save button:
<button type="button"
class="btn btn-success btn-sm"
ng-click="vm.UpdateOrderDetail()">Save</button>
This calls the UpdateOrderDetail() method that adds or updates the selection in the colOrderDetails collection of the selectedOrder:
UpdateOrderDetail() {
// Validate that we have a valid selectedOrderDetail.
// We must have a product and the Quantity must be filled in
// and Quantity must be a valid number
if (vm.selectedOrderDetail.Product == null
|| isNaN(vm.selectedOrderDetail.Quantity)
|| vm.selectedOrderDetail.Quantity.toString() == "") {
// Show an error message
alert("Product and Quantity are required");
} else {
// If Id = 0 this is an insert
if (vm.selectedOrderDetail.Id === 0) {
// Change Id to -1 so we will know it not an insert
// next time the record comes to this method
vm.selectedOrderDetail.Id = -1;
// Add the selectedOrderDetail to the colOrderDetails
// collection of the Order.
vm.selectedOrder.colOrderDetails.push(vm.selectedOrderDetail);
}
// Close the OrderDetail dialog
$('#EditOrderDetail').modal('hide')
}
}
The binding in Angular will causes the OrderDetail item to show in the table on the Edit Order popup due to the binding markup that is used.
The following shows the complete markup for the table:
<table class="table table-hover
table-bordered table-striped table-responsive table-condensed">
<thead>
<tr>
<th align="center">Edit</th>
<th>Product</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="OrderDetail in vm.selectedOrder.colOrderDetails track by $index"
id="{{OrderDetail.Id}}"
class="active">
<td align="center">
<button type="button"
id="EditOrderDetailButton"
title="Edit"
class="btn btn-primary btn-xs glyphicon glyphicon-edit"
onclick="$('#EditOrderDetail').modal('show');"
ng-click="vm.selectedOrderDetail = OrderDetail">
</button>
<button type="button"
id="DeleteOrderButton"
title="Delete"
class="btn btn-danger btn-xs glyphicon glyphicon-trash"
ng-click="vm.selectedOrderDetail = OrderDetail ; vm.DeleteOrderDetail()">
</button>
</td>
<td><span>{{OrderDetail.Product.ProductName}}</span></td>
<td align="center">{{OrderDetail.Quantity}}</td>
</tr>
</tbody>
</table>
The ng-repeat is the key piece of code that loops through items in the colOrderDetails collection of the selectedOrder.
Save Order
An Order consists of the Order entity and possibly multiple OrderDetail entities that each have an associated Product.
The Save button on the Edit Order popup is interesting in that it saves them all in a single call.
The following is the markup for the Save button:
<button type="button"
class="btn btn-success btn-sm"
ng-click="vm.SaveOrder()">Save</button>
This calls the SaveOrder() method that will perform a PUT if it is update and a POST if it is an insert:
SaveOrder() {
// Construct the path to the OData end point
var urlString: string = "";
var method: string = "";
if (vm.selectedOrder.Id !== 0) {
// Perform an Update
urlString = vm.baseUrl + "odata/ODataOrders(" + vm.selectedOrder.Id + ")";
method = "PUT";
} else {
// Perform an Insert
urlString = vm.baseUrl + "odata/ODataOrders";
method = "POST";
}
// Create an Order to pass to the OData method
var objOrder: Order = new Order();
objOrder.Id = vm.selectedOrder.Id;
objOrder.OrderDate = vm.selectedOrder.OrderDate;
objOrder.UserName = vm.selectedOrder.UserName;
objOrder.colOrderDetails = new Array<OrderDetail>();
vm.selectedOrder.colOrderDetails.forEach(OrderDetail => {
// Make a copy of the OrderDetail record so that the
// Actual record is not affected when we wipe out the
// Product property
var objOrderDetail: OrderDetail = vm.copyObject(OrderDetail);
// Set the ProductId
objOrderDetail.ProductId = OrderDetail.Product.Id;
// Clear Product out so Odata method wont fail
// OData only handles one level of nesting not
// the two levels that passing the Product would require.
objOrderDetail.Product = undefined;
objOrder.colOrderDetails.push(objOrderDetail);
});
var result: ng.IPromise<GetOrderResults> = vm.$http({
url: urlString,
method: method,
// Pass the Order (and its OrderDetails)
// to the OData method
data: objOrder
});
// Process the result of the OData call
// If successful, the Success method will be called
// If not successful the Failure method will be called
result.then(Success, vm.Failure)
function Success(results: GetOrderResults) {
// Set the selectedOrder to the Order that
// was pased back from the OData method
// If this is an insert
// No data is passed if this is an update
if (results.data !== undefined) {
vm.selectedOrder = results.data;
}
// Refresh the Orders
vm.GetAllOrders();
// Close the Order dialog
$('#EditOrderModal').modal('hide')
}
}
We create a new ODataOrdersController.cs file with the following markup:
#region public IHttpActionResult Post(DTOOrder dtoOrder)
// POST: odata/ODataOrders
[Authorize]
public IHttpActionResult Post(DTOOrder dtoOrder)
{
// Create a new Order
var NewOrder = new Order();
NewOrder.OrderDate = Convert.ToDateTime(dtoOrder.OrderDate);
NewOrder.UserName = this.User.Identity.Name;
NewOrder.OrderDetails = new List<OrderDetail>();
// Add the Order Details
// Loop through all the Order Details passed
foreach (var orderDetail in dtoOrder.colOrderDetails)
{
// Create a new Order Detail
var NewOrderDetail = new OrderDetail();
// Set the Quanity
NewOrderDetail.Quantity = orderDetail.Quantity;
// Get the Product from the ProductId
NewOrderDetail.Product =
db.Products.Where(x => x.Id == orderDetail.ProductId)
.FirstOrDefault();
// Add the Product to the Order
NewOrder.OrderDetails.Add(NewOrderDetail);
}
// Save the Order
db.Orders.Add(NewOrder);
db.SaveChanges();
// Populate the ID that was created and pass it back
dtoOrder.Id = NewOrder.Id;
return Created(dtoOrder);
}
#endregion
#region public IHttpActionResult Put([FromODataUri] int key, DTOOrder dtoOrder)
// PUT: odata/ODataOrders(1)
[Authorize]
public IHttpActionResult Put([FromODataUri] int key, DTOOrder dtoOrder)
{
Order ExistingOrder = db.Orders.Find(key);
if (ExistingOrder == null)
{
return StatusCode(HttpStatusCode.NotFound);
}
// Only Administrators or the orginal user
// can edit the Order
if (!this.User.IsInRole("Administrator"))
{
if (ExistingOrder.UserName != this.User.Identity.Name)
{
throw new Exception(
String.Format("Record belongs to {0} and the curent user is {1}",
ExistingOrder.UserName,
this.User.Identity.Name));
}
}
// Update the Order Date
ExistingOrder.OrderDate = Convert.ToDateTime(dtoOrder.OrderDate);
// Delete all existing Order Details
db.Database.ExecuteSqlCommand("Delete from OrderDetails where OrderId = @OrderId",
new SqlParameter("@OrderId", ExistingOrder.Id));
db.SaveChanges();
// Add the Order Details
// Loop through all the Order Details passed
foreach (var orderDetail in dtoOrder.colOrderDetails)
{
// Create a new Order Detail
var NewOrderDetail = new OrderDetail();
// Set the Foreign key to Order
NewOrderDetail.OrderId = ExistingOrder.Id;
// Set the Quanity
NewOrderDetail.Quantity = orderDetail.Quantity;
// Get the Product from the ProductId
NewOrderDetail.Product =
db.Products.Where(x => x.Id == orderDetail.ProductId)
.FirstOrDefault();
// Add the Product to the Order
ExistingOrder.OrderDetails.Add(NewOrderDetail);
}
// Save changes
db.Entry(ExistingOrder).State = EntityState.Modified;
db.SaveChanges();
return Updated(dtoOrder);
}
#endregion
To enable this OData method, we add the following code to the GenerateEntityDataModel() method in the WebApiConfig.cs file:
// Register ODataOrders
builder.EntitySet<DTOOrder>("ODataOrders");
Show Orders
The main screen shows the existing Orders and has a pager bar at the bottom to page through the results.
The following code shows the markup for the table:
<table style="width: auto;"
class="table table-hover table-bordered table-striped table-responsive table-condensed">
<thead>
<tr>
<th width="20%">Edit</th>
<th width="40%">User Name</th>
<th width="40%">Order Date</th>
</tr>
</thead>
<tbody ng-cloak>
<tr ng-repeat="Order in vm.colOrders track by $index"
id="{{OrderDetail.Id}}"
class="active">
<td align="center">
<button type="button"
id="EditOrderDetailButton"
title="Edit"
class="btn btn-primary btn-xs glyphicon glyphicon-edit"
onclick="$('#EditOrderModal').modal('show');"
ng-click="vm.selectedOrder = Order ; vm.GetOrderDetails()"></button>
<button type="button"
id="DeleteOrderButton"
title="Delete"
class="btn btn-danger btn-xs glyphicon glyphicon-trash"
ng-click="vm.selectedOrder = Order ; vm.DeleteOrder()"></button>
</td>
<td><span>{{Order.UserName}}</span></td>
<td><span>{{Order.OrderDate}}</span></td>
</tr>
</tbody>
</table>
Note: The markup template also indicates that the GetOrderDetails() method will be called when an item is edited and the DeleteOrder() method will be called when an item is deleted. These methods will be covered later.
The markup for the paging control is as follows:
<uib-pagination boundary-links="true"
max-size="25"
items-per-page="5"
total-items="vm.totalOrderCount"
ng-model="vm.currentOrderPage"
ng-change="vm.GetAllOrders()"></uib-pagination>
As the markup indicates, when a change is made on the paging control (a user clicks one of the buttons) the GetAllOrders() method is called.
This is the AngularJs TypeScript code for that method:
GetAllOrders() {
// Construct the path to the OData end point
// Handle paging
var urlString: string = vm.baseUrl
+ "odata/ODataOrders?$count=true&$top=5&$skip=" + ((vm.currentOrderPage * 5)-5);
// Call the OData end point
var result: ng.IPromise<GetOrderArrayResults> = vm.$http({
url: urlString,
method: "GET"
});
// Process the result of the OData call
// If successful, the Success method will be called
// If not successful the Failure method will be called
result.then(Success, vm.Failure)
// Handle a successful call
function Success(results: GetOrderArrayResults) {
// Fill the Orders collection
vm.colOrders = results.data.value;
// Handle Paging -- set total records
// This is returned because we set $count=true
// in the OData query
vm.totalOrderCount = results.data['@odata.count'];
}
}
This method calls the following server-side GetODataOrders OData method.
Note: That while the AngularJs code just makes a call to ODataOrders, the server-side OData method is actually called GetODataOrders. The reason is that when calling an OData queryable method (one that implements IQueryable) you use the word Get in front of the method name.
This is the code for the GetODataOrders method:
// GET: odata/ODataOrders
[Authorize]
[EnableQuery(PageSize = 100, AllowedQueryOptions = AllowedQueryOptions.All)]
public IQueryable<DTOOrder> GetODataOrders()
{
var result = (from Orders in db.Orders
select new DTOOrder
{
Id = Orders.Id,
UserName = Orders.UserName,
OrderDate =
SqlFunctions.StringConvert((double)Orders.OrderDate.Month).TrimStart()
+ "/"
+ SqlFunctions.DateName("day", Orders.OrderDate)
+ "/"
+ SqlFunctions.DateName("year", Orders.OrderDate)
});
// Only Administrators can see all records
if (!this.User.IsInRole("Administrator"))
{
result = result
.Where(x => x.UserName == this.User.Identity.Name);
}
return result.AsQueryable();
}
The key takeaway is that this method returns IQueryable so it can be queried. In our example, we pass to the method, a query that indicates how many records we want (“top” in the TypeScript code) and what page of the records we want (“skip” in the TypeScript code).
The following diagram shows how the paging works:
Edit Order
When an Order is edited, the following code is used for the markup of the button:
<button type="button"
id="EditOrderDetailButton"
title="Edit"
class="btn btn-primary btn-xs glyphicon glyphicon-edit"
onclick="$('#EditOrderModal').modal('show');"
ng-click="vm.selectedOrder = Order ; vm.GetOrderDetails()"></button>
The ng-click first sets the Order as the selectedOrder. It also calls the GetOrderDetails() method. This is the code for the method:
GetOrderDetails() {
// Get the name of the current user
// Construct the path to the OData end point
var urlString: string = vm.baseUrl
+ "odata/OrderDetailsForOrder(OrderId=" + vm.selectedOrder.Id + ")";
var result: ng.IPromise<GetOrderDetailsArrayResults> = vm.$http({
url: urlString,
method: "POST"
});
// Process the result of the OData call
// If successful, the Success method will be called
// If not successful the Failure method will be called
result.then(Success, vm.Failure)
function Success(results: GetOrderDetailsArrayResults) {
// Clear the colOrderDetails
vm.selectedOrder.colOrderDetails = new Array<OrderDetail>();
// Loop through the OrderDetails
results.data.value.forEach(OrderDetail => {
// Loop through the Products collection
vm.colProducts.forEach(Product => {
// Did we find the Product.Id
// we are looking for?
if (Product.Id == OrderDetail.ProductId) {
// Set the Product
OrderDetail.Product = Product;
}
});
// Add the OrderDetail to the
// colOrderDetails collection
vm.selectedOrder.colOrderDetails.push(OrderDetail);
});
}
}
What is interesting about this code is that it calls the OData server-side method OrderDetailsForOrder([FromODataUri] int OrderId).
This method , unlike the GetODataOrders() method, covered earlier, returns data from the Order as well as the details for the Order.
This is the code for the method:
// odata/OrderDetailsForOrder(OrderId=1)
[ODataRoute("OrderDetailsForOrder(OrderId={OrderId})")]
public IHttpActionResult OrderDetailsForOrder([FromODataUri] int OrderId)
{
Order ExistingOrder = db.Orders.Find(OrderId);
if (ExistingOrder == null)
{
return StatusCode(HttpStatusCode.NotFound);
}
// Only Administrators or the orginal user
// can delete the Order
if (!this.User.IsInRole("Administrator"))
{
if (ExistingOrder.UserName != this.User.Identity.Name)
{
throw new Exception(
String.Format("Record belongs to {0} and the curent user is {1}",
ExistingOrder.UserName,
this.User.Identity.Name));
}
}
// Get the OrderDetails
var result = (from OrderDetail in db.OrderDetails
where OrderDetail.OrderId == OrderId
select new DTOOrderDetail
{
Id = OrderDetail.Id,
ProductId = OrderDetail.Product.Id,
Quantity = OrderDetail.Quantity
}).ToList();
return Ok(result);
}
We also need to add the following code to the GenerateEntityDataModel() method in the WebApiConfig.cs file:
// OrderDetailsForOrder function returns OrderDetails
var OrderDetailsForOrder = builder.Function("OrderDetailsForOrder");
OrderDetailsForOrder.ReturnsCollectionFromEntitySet<DTOOrderDetail>("DTOOrderDetails");
OrderDetailsForOrder.Parameter<int>("OrderId");
Essentially, OData 4 allows you to create methods that can take whatever parameters you want, and return an entity of any shape you need.
Note: Another method is to use Entity Relations as covered here: http://www.asp.net/web-api/overview/odata-support-in-aspnet-web-api/odata-v4/entity-relations-in-odata-v4
Delete
When an Order is deleted, the following code is used for the markup of the button:
<button type="button"
id="DeleteOrderButton"
title="Delete"
class="btn btn-danger btn-xs glyphicon glyphicon-trash"
ng-click="vm.selectedOrder = Order ; vm.DeleteOrder()"></button>
The ng-click first sets the Order as the selectedOrder. It also calls the DeleteOrder() method. This is the code for the method:
DeleteOrder() {
var urlString: string = vm.baseUrl + "odata/ODataOrders(" + vm.selectedOrder.Id + ")";
var result: any = vm.$http({
url: urlString,
method: "DELETE"
});
// Process the result of the OData call
// If successful, the Success method will be called
// If not successful the Failure method will be called
result.then(Success, vm.Failure)
function Success(Order: any) {
// Refresh Orders
vm.GetAllOrders();
}
}
This method calls the following server-side OData method:
// DELETE: odata/ODataOrders(1)
[Authorize]
public IHttpActionResult Delete([FromODataUri] int key)
{
Order ExistingOrder = db.Orders.Find(key);
if (ExistingOrder == null)
{
return StatusCode(HttpStatusCode.NotFound);
}
// Only Administrators or the orginal user
// can delete the Order
if (!this.User.IsInRole("Administrator"))
{
if (ExistingOrder.UserName != this.User.Identity.Name)
{
throw new Exception(
String.Format("Record belongs to {0} and the current user is {1}",
ExistingOrder.UserName,
this.User.Identity.Name));
}
}
// Delete the Order
// (and any Order Detail(s))
db.Orders.Remove(ExistingOrder);
// Save changes
db.SaveChanges();
return StatusCode(HttpStatusCode.NoContent);
}
Special Thanks
A special thanks to Fatima Masroora and Eva Ng and Richard Waddell @qfeguy for their coding assistance.
Links
Step-By-Step OData 4 / TypeScript / AngularJs / CRUD Sample
Documentation · OData - the Best Way to REST
Create an OData v4 Endpoint Using ASP.NET Web API 2.2 ...
TypeScrip.org
DefinitelyTyped
AngularJS — Superheroic JavaScript MVW Framework
Angular Applications with TypeScript
Download
The LightSwitch project is available at http://lightswitchhelpwebsite.com/Downloads.aspx
You must have Visual Studio 2015 (or higher) with LightSwitch installed to run the code (if you have Visual Studio Community Edition see How To Get Visual Studio LightSwitch For Free)
22 comment(s) so far...
I recently created an OData V4 data service using the non-LightSwitch non-WCF Web API 2 approach and managed to consume the data using ODatajs for V4 and Datajs for the older LightSwitch V3 svc. Interesting to write the client using Notepad (well that and VSCode). Pain, pain and then more pain heaped upon pain. LightSwitch protected me from so many pain points. Still it has given me a greater appreciation of how OData delivers it's SQL data down the pipes. I just wish you'd written this great article a month or so back ;). Fantastic work again thx Michael.
Sorry I accidentally added this comment to one of you old blogs before I realised that it wasn't the blog that I wanted to comment on.
By Lloyd on
3/29/2016 7:07 AM
|
@Lloyd - Thanks :)
By Michael Washington on
3/29/2016 7:08 AM
|
Michael,
I don't mean this question to be 'loaded', but is there any knowledge of if/when Lightswitch HTML will no longer be supported?
eg. Why did you write this article? In the sense that - is there a reason we need "to move from Visual Studio LightSwitch to the latest web technologies". To my beginner eye, there seems to be much more involved here compared to what Lightswitch can 'already' do.
After doing some googling, it seems that many think that Lightswitch has been abandoned by Microsoft. Would that be accurate to say?
By Kon on
3/29/2016 9:44 PM
|
@Kon - LightSwitch has not had any major updates in years. This is why I have explored other options on new projects. However, I continue to maintain my existing LightSwitch applications.
By Michael Washington on
3/29/2016 9:46 PM
|
I think you'd meant to link: Creating User and Roles Administration Pages for an MVC5 Application
to: http://openlightgroup.com/Blog/TabId/58/PostId/189/UserRolesAdministration.aspx
but you never created the link.
By Lloyd on
3/31/2016 3:36 AM
|
@Lloyd - I fixed the link, Thanks :)
By Michael Washington on
3/31/2016 3:40 AM
|
Great article Michael, thanks for it. May I ask if you have any thoughts on whether its possible to take the intrinsic database from an existing LS app and pre-generate a backend for an app like this, perhaps using Entity Framework database-first tools?
Also, do you have any thoughts on how to secure the OData service you're providing, to keep hackers from having fun with your data?
By larry q on
4/5/2016 8:19 AM
|
@larry q - The OData end point is secured by the [Authorize] attribute :) Also, RESTier can create part of a back-end See: http://www.odata.org/blog/restier-a-turn-key-framework-to-build-restful-service/
By Michael Washington on
4/5/2016 8:21 AM
|
Michael, I know that today we are not supporting the lightswitch and you invested a lot of time with ls, and I invested.
I have many projects in lightswitch silverlight (desktop / web).
I would very much like your help to make an application on Microsoft for a migration tool for asp.net mvc.
and if you know any details of the future of the lightswitch. please, warn me.
thank you Rodrigo
By rodrigo on
4/6/2016 6:35 PM
|
I am sad to know that we can no longer rely on the lightswitch, a complete tool, with validation rules and permissions in a simple and fast way users.
and we have no substitute, with so many facilities without using codes
By rodrigo on
4/6/2016 6:35 PM
|
@Rodrigo - I have many LightSwitch projects that I will continue to maintain. As far as future projects I am using the method described in this article. I do not believe that a conversion tool would work, sorry.
By Michael Washington on
4/6/2016 6:36 PM
|
Thanks for the correction Michael-- I didn't notice the [Authorize] attribute on the read method, just the POST and PUT ones. Are you looking at any 3rd party widgets to spice up the UI, that might work well with Angular? (The less CSS, html markup and Bootstrap code I have to add myself the better for everyone.)
By larry q on
4/7/2016 10:21 AM
|
@larry q - As far as spicing up the UI, I have been really happy with all that Bootstrap. When I need more, I use Wijmo because they are very deeply integrated with Angular. I have some articles on this site with sample code that show Wijmo controls and Angular.
By Michael Washington on
4/7/2016 10:24 AM
|
hello Michael , any news about the ls ?
bring it back !!!Michael !!!!
By rodrigo on
8/21/2016 3:00 PM
|
@rodrigo - No news, sorry.
By Michael Washington on
8/21/2016 3:00 PM
|
Can this application be created in Visual Studio 2013? In VS 2013 I can't find NuGet Package Manager Console.
By Mike on
8/27/2016 7:22 AM
|
@Mike - I think NuGet Package Manager Console is called something else in VS2013. However, this tutorial is very VS2015 specific so just go ahead and download it because it is free :)
By Michael Washington on
8/27/2016 7:23 AM
|
Any chance of popping this sample app on GitHub? It has a lot of useful elements that the community could use and contribute to as the migrations start to happen away from LightSwitch ...
By OzBobWa on
10/17/2016 8:38 AM
|
@OzBobWa - This is just a sample not a project that I plan to alter. I need to move on to other things. I may post an Angular2 version that would be a total re-write.
By Michael Washington on
10/17/2016 8:39 AM
|
Perfectly understand. Good luck with the your AI/Data/Bots thing over at http://AiHelpWebsite.com
By OzBobWa on
10/17/2016 8:18 PM
|
That's a lot of code for an extremely simple application, wouldn't one of the frameworks like Vaadin or Wavemaker be a simpler way to go? Plus it doesn't provide a way of migrating a LS application, just a way of rewriting it. It seems in many ways we have taken a giant leap backward in the last 20 years, I almost crave the days of Windows Forms when you could just drag and drop and double click for the code behind. Why should it be so hard to do that for the web environment? Come back Alan Cooper all is forgiven.
By Charlie on
10/18/2016 4:20 AM
|
@Charlie - LightSwitch was a unique tooling framework in that it allowed me to always "code my way out of any problem". The only thing I can compare it to is Microsoft Access. So, for me, I recommend a recode rather than another tooling framework.
By Michael Washington on
10/18/2016 4:22 AM
|