Introduction
In Master Chef (part 1) and Master Chef (part 2),I introduced how to use ASP.Net Core and Angular JS. From Master Chef (part 3), I begin to introduce how to create ASP.NET Core and Angular 2 applications. In this article, I continuely talk about how to use ASP.NET Core MVC, Entity Framework Core and Angular 2 to implement a CRUD SPA (Single Page Application).
Server Data Model
Create, read, update and delete (as an acronym CRUD) are four basic functions of persistent storage.
We need to implement CRUD on database level in our repository class first. Add a basic Entity class.
public class Entity
{
public virtual Guid Id { get; set; }
public virtual Guid? ParentId { get; set; }
}
Then let Recipe
, RecipeStep
and RecipeItem
inherit Entity
class, and use Id and ParentId these generic names to replace the corresponding keys and references.
public partial class Recipe : Entity
{
public Recipe()
{
RecipeSteps = new HashSet<RecipeStep>();
}
public string Name { get; set; }
public DateTime ModifyDate { get; set; }
public string Comments { get; set; }
public virtual ICollection<RecipeStep> RecipeSteps { get; set; }
}
public partial class RecipeStep : Entity
{
public RecipeStep()
{
RecipeItems = new HashSet<RecipeItem>();
}
public int StepNo { get; set; }
public string Instructions { get; set; }
public virtual ICollection<RecipeItem> RecipeItems { get; set; }
[JsonIgnore]
public Recipe Recipe { get; set; }
}
public partial class RecipeItem : Entity
{
public string Name { get; set; }
public decimal Quantity { get; set; }
public string MeasurementUnit { get; set; }
[JsonIgnore]
public RecipeStep RecipeStep { get; set; }
}
Now we need change the DbContext
class to apply Id
and ParentId
.
modelBuilder.Entity<RecipeItem>(entity =>
{
entity.HasKey(e => e.Id)
.HasName("PK_RecipeItems");
entity.Property(e => e.Id).ValueGeneratedNever().HasColumnName("ItemId");
entity.Property(e => e.ParentId).HasColumnName("RecipeStepId");
entity.Property(e => e.MeasurementUnit)
.IsRequired()
.HasColumnType("varchar(20)");
entity.Property(e => e.Name)
.IsRequired()
.HasColumnType("varchar(255)");
entity.Property(e => e.Quantity).HasColumnType("decimal");
entity.HasOne(d => d.RecipeStep)
.WithMany(p => p.RecipeItems)
.HasForeignKey(d=>d.ParentId)
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("FK_RecipeItems_RecipeSteps");
});
For RecipeItem
entity, we use "HasColumnName
" to tell model builder the mappings, "Id
" mapping to "ItemId
" and "ParentId
" mapping to "RecipeStepId
". Then in reference definition, change HasForeignKey(d=>d.RecipeStepId)
to HasForeignKey(d=>d.ParentId)
.
The same solution for RecipeStep
:
modelBuilder.Entity<RecipeStep>(entity =>
{
entity.HasKey(e => e.Id)
.HasName("PK_RecipeSteps");
entity.Property(e => e.Id).ValueGeneratedNever().HasColumnName("RecipeStepId");
entity.Property(e => e.ParentId).HasColumnName("RecipeId");
entity.Property(e => e.Instructions).HasColumnType("text");
entity.HasOne(d => d.Recipe)
.WithMany(p => p.RecipeSteps)
.HasForeignKey(d => d.ParentId)
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("FK_RecipeSteps_Recipes");
});
What’s DeleteBehavior.Cascade
? This is the option that deletes children when you delete the parent object. For our case, removing a recipe will delete all recipe steps and recipe items of this recipe, and removing a step will delete all items of this step.
Recipe class doesn’t have ParentId
. So we need tell model builder to ignore the mapping.
modelBuilder.Entity<Recipe>(entity =>
{
entity.HasKey(e => e.Id)
.HasName("PK_Recipes");
entity.Ignore(e => e.ParentId);
entity.Property(e => e.Id).ValueGeneratedNever().HasColumnName("RecipeId");
entity.Property(e => e.Comments).HasColumnType("text");
entity.Property(e => e.ModifyDate).HasColumnType("date");
entity.Property(e => e.Name)
.IsRequired()
.HasColumnType("varchar(255)");
});
After applying these changes, now we can use generics in a repository class to implement, create, read, update and delete functionality for Recipe
, RecipeStep
and RecipeItem
.
public T GetEntity<T>(Guid id) where T : Entity
{
try
{
return _dbContext.Find<T>(id);
}
catch (Exception ex)
{
throw ex;
}
}
public T AddEntity<T>(T entity) where T : Entity
{
_dbContext.Add<T>(entity);
_dbContext.SaveChanges();
var result = GetEntity<T>(entity.Id);
return result;
}
public void UpdateEntity<T>(T entity) where T : Entity
{
_dbContext.Update<T>(entity);
_dbContext.SaveChanges();
}
public void DeleteEntity<T>(Guid id) where T : Entity
{
var entity = GetEntity<T>(id);
_dbContext.Remove<T>(entity);
_dbContext.SaveChanges();
}
Web API Controller
In the RecipesController
class, we set up functions to deal with basic CRUD requests. We're getting a GET request here requesting all recipes. We have another Get function here that takes an id, so a user can request a specific recipe to return. And we also have some more functions here POST
– allowing a user to create a new recipe. And also PUT
– where we can update an existing recipe. And finally, DELETE
– where a specific recipe can be deleted.
[HttpGet("{id}")]
public IActionResult Get(Guid id)
{
var recipe = _repository.GetEntity<Recipe>(id);
if (recipe != null)
return new ObjectResult(recipe);
else
return new NotFoundResult();
}
[HttpPost]
public IActionResult Post([FromBody]Recipe recipe)
{
if (recipe.Id == Guid.Empty)
{
recipe.Id = Guid.NewGuid();
recipe.ModifyDate = DateTime.Now;
return new ObjectResult(_repository.AddEntity<Recipe>(recipe));
}
else
{
var existingOne = _repository.GetEntity<Recipe>(recipe.Id);
existingOne.Name = recipe.Name;
existingOne.Comments = recipe.Comments;
existingOne.ModifyDate = DateTime.Now;
_repository.UpdateEntity<Recipe>(existingOne);
return new ObjectResult(existingOne);
}
}
[HttpPut("{id}")]
public IActionResult Put(Guid id, [FromBody]Recipe recipe)
{
var existingOne = _repository.GetEntity<Recipe>(recipe.Id);
existingOne.Name = recipe.Name;
existingOne.Comments = recipe.Comments;
_repository.UpdateEntity<Recipe>(existingOne);
return new ObjectResult(existingOne);
}
[HttpDelete("{id}")]
public IActionResult Delete(Guid id)
{
_repository.DeleteEntity<Recipe>(id);
return new StatusCodeResult(200);
}
But how about RecipeStep
and RecipeItem
? Can we put different HttpGet
, HttpPost
and HttpDelete
in one API controller?
Routing is how Web API matches a URI to an action. Web API 2 supports a new type of routing, called attribute routing. As the name implies, attribute routing uses attributes to define routes. Attribute routing gives you more control over the URIs in your web API. For example, you can easily create URIs that describe hierarchies of resources.
So now we use attribute routing to define multiple HTTPGet
, HTTPPost
and HTTPDelete
in one API controller.
[HttpGet]
[Route("step/{id}")]
public IActionResult GetStep(Guid id)
{
var recipeStep = _repository.GetEntity<RecipeStep>(id);
if (recipeStep != null)
return new ObjectResult(recipeStep);
else
return new NotFoundResult();
}
[HttpPost]
[Route("step")]
public IActionResult UpdateStep([FromBody]RecipeStep recipeStep)
{
if (recipeStep.Id == Guid.Empty)
{
recipeStep.Id = Guid.NewGuid();
return new ObjectResult(_repository.AddEntity<RecipeStep>(recipeStep));
}
else
{
var existingOne = _repository.GetEntity<RecipeStep>(recipeStep.Id);
existingOne.StepNo = recipeStep.StepNo;
existingOne.Instructions = recipeStep.Instructions;
_repository.UpdateEntity<RecipeStep>(existingOne);
return new ObjectResult(existingOne);
}
}
[HttpDelete]
[Route("step/{id}")]
public IActionResult DeleteStep(Guid id)
{
_repository.DeleteEntity<RecipeStep>(id);
return new StatusCodeResult(200);
}
[HttpGet]
[Route("item/{id}")]
public IActionResult GetItem(Guid id)
{
var recipeItem = _repository.GetEntity<RecipeItem>(id);
if (recipeItem != null)
return new ObjectResult(recipeItem);
else
return new NotFoundResult();
}
[HttpPost]
[Route("item")]
public IActionResult UpdateItem([FromBody]RecipeItem recipeItem)
{
if (recipeItem.Id == Guid.Empty)
{
recipeItem.Id = Guid.NewGuid();
if (recipeItem.MeasurementUnit == null)
recipeItem.MeasurementUnit = "";
return new ObjectResult(_repository.AddEntity<RecipeItem>(recipeItem));
}
else
{
var existingOne = _repository.GetEntity<RecipeItem>(recipeItem.Id);
existingOne.Name = recipeItem.Name;
existingOne.Quantity = recipeItem.Quantity;
existingOne.MeasurementUnit = recipeItem.MeasurementUnit;
_repository.UpdateEntity<RecipeItem>(existingOne);
return new ObjectResult(existingOne);
}
}
[HttpDelete]
[Route("item/{id}")]
public IActionResult DeleteItem(Guid id)
{
_repository.DeleteEntity<RecipeItem>(id);
return new StatusCodeResult(200);
}
Client View Models
In last article, we have created a recipe view model. Now we continually create recipestep and recipeitem.
Right click "viewmodels" to add new type script file. It’s named "recipeStep," which is a recipe step view model we use to display on the view.
export class RecipeStep {
public parentId: string;
public id: string;
public stepNo: number;
public instructions: string;
constructor() { }
}
Right click "viewmodels" to add another type script file. It’s named "recipeItem," which is a recipe item view model we use to display on the view.
Client Side Service
In our client side service "app.service.ts," we need add more methods to do the CRUD functionality.
Import client view model classes first.
import { Recipe } from "../viewmodels/recipe";
import { RecipeStep } from "../viewmodels/recipeStep";
import { RecipeItem } from "../viewmodels/recipeItem";
import { Observable } from "rxjs/Observable";
Please note as we implemented in web API controller, URL is different for recipe, step and item.
So in service class, we define three constant URL strings.
private recipeUrl = 'api/recipes/';
private stepUrl = 'api/recipes/step/';
private itemUrl = 'api/recipes/item/';
Get, update, and delete recipe methods:
getRecipe(id: string) {
if (id == null) throw new Error("id is required.");
var url = this.recipeUrl + id;
return this.http.get(url)
.map(response => <Recipe>response.json())
.catch(this.handleError);
}
saveRecipe(recipe: Recipe) {
if (recipe == null) throw new Error("recipe is required.");
var url = this.recipeUrl;
return this.http.post(url, recipe)
.map(response => <Recipe>response.json())
.catch(this.handleError);
}
deleteRecipe(id:string) {
if (id == null) throw new Error("id is required.");
var url = this.recipeUrl + id;
return this.http.delete(url)
.catch(this.handleError);
}
Get, update and delete recipe step methods:
getStep(id: string) {
if (id == null) throw new Error("id is required.");
var url = this.stepUrl + id;
return this.http.get(url)
.map(response => <RecipeStep>response.json())
.catch(this.handleError);
}
saveStep(step: RecipeStep) {
if (step == null) throw new Error("recipe step is required.");
var url = this.stepUrl;
return this.http.post(url, step)
.map(response => <RecipeStep>response.json())
.catch(this.handleError);
}
deleteStep(id: string) {
if (id == null) throw new Error("id is required.");
var url = this.stepUrl + id;
return this.http.delete(url)
.catch(this.handleError);
}
Get, update and delete recipe item methods:
getItem(id: string) {
if (id == null) throw new Error("id is required.");
var url = this.itemUrl + id;
return this.http.get(url)
.map(response => <RecipeItem>response.json())
.catch(this.handleError);
}
saveItem(item: RecipeItem) {
if (item == null) throw new Error("recipe item is required.");
var url = this.itemUrl;
return this.http.post(url, item)
.map(response => <RecipeItem>response.json())
.catch(this.handleError);
}
deleteItem(id: string) {
if (id == null) throw new Error("id is required.");
var url = this.itemUrl + id;
return this.http.delete(url)
.catch(this.handleError);
}
Client Side Routing
Using MVC in ASP.NET, you used routing whenever you specified what controller you expected your code to hit when you specified a particular URL. We also had the option of specifying parameters that we wanted to have passed into our controller methods. That’s the server side routing.
In a SPA, client side routing does essentially the same thing. The only difference is that we never have to call the server. This makes all our "Pages" virtual. Instead of requiring that our visitors always start at our home page and navigate into the rest of our site; instead of creating a separate page on the server for each page in our site; we can load all the site up front and the user can navigate to exactly the page they want to be at. They can even link directly to that page and the client side will handle displaying the page appropriately.
Typically routes get enabled at the top of your application after all the common code has been implemented. So, in the location where you want the routing to take effect, add the following tag:
<router-outlet></router-outlet>
App Component
Now we change our App Component to enable client side routing to implement Single Page Application.
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Recipe } from "./viewmodels/recipe";
import { AppService } from "./services/app.service";
@Component({
selector: 'masterchef2',
template: `
<h1>{{title}}</h1>
<router-outlet></router-outlet>
`
})
export class AppComponent {
title = "Master Chef Recipes";
}
You can see now App Component is very simple. Only display a title. <router-outlet></router-outlet> will bring different templates according to the path.
Recipe List Component
In the last article, we put recipe list in app component. Because we need to implement more complicated functionalities, I take it out from app component.
Right click "scripts/app/components" folder, add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "recipe-list.component.ts".
import { Recipe } from "../viewmodels/recipe";
import { AppService } from "../services/app.service";
@Component({
selector: 'recipe-list',
templateUrl: '../partials/recipes.html'
})
export class RecipeListComponent implements OnInit {
items: Recipe[];
errorMessage: string;
constructor(private appService: AppService) {
}
ngOnInit() {
var service = this.appService.getAllRecipes();
service.subscribe(
items => {
this.items = items;
},
error => this.errorMessage = <any>error
);
}
public Expand(recipe:Recipe) {
recipe.show = !recipe.show;
}
}
Please note the change in the Expand
method. Now the "show" property is not in the component level anymore. It’s moved to Recipe view model. That is because I’d like to control each recipe, not all recipes.
Recipe Detail Component
Right click the "scripts/app/components" folder, and add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "recipe-detail.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { Recipe } from "../viewmodels/recipe";
import { AppService } from "../services/app.service";
@Component({
selector: 'recipe-detail',
templateUrl: '../partials/edit.html'
})
export class RecipeDetailComponent implements OnInit {
item: Recipe;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var id = params['id'];
this.AppService.getRecipe(id).subscribe(item => this.item = item);
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public editRecipe() {
this.AppService.saveRecipe(this.item).subscribe(
item => { this.item = item; this.router.navigate(['/recipes']); },
error => console.log(error)
)
}
}
In this class, we call the getRecipe
service function to get recipe information first, then call the saveRecipe
service function to update recipe.
Recipe New Component
Right click the "scripts/app/components" folder, add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "recipe-new.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { Recipe } from "../viewmodels/recipe";
import { AppService } from "../services/app.service";
@Component({
selector: 'recipe-new',
templateUrl: '../partials/add.html'
})
export class RecipeNewComponent implements OnInit {
item: Recipe;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.item = new Recipe();
}
ngOnDestroy() {
}
public addRecipe() {
this.AppService.saveRecipe(this.item).subscribe(
item => { this.item = item; this.router.navigate(['/recipes']); },
error => console.log(error)
)
}
}
In this class, we create a new recipe first, then call the saveRecipe
service function to add recipe.
Recipe Delete Component
Right click the "scripts/app/components" folder, and add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "recipe-delete.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { Recipe } from "../viewmodels/recipe";
import { AppService } from "../services/app.service";
@Component({
selector: 'recipe-delete',
templateUrl: '../partials/delete.html'
})
export class RecipeDeleteComponent implements OnInit {
item: Recipe;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var id = params['id'];
this.AppService.getRecipe(id).subscribe(item => this.item = item);
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public deleteRecipe() {
this.AppService.deleteRecipe(this.item.id).subscribe(
() => this.router.navigate(['/recipes']),
error => console.log(error)
)
}
}
In this class, we call the getRecipe
service function to get recipe information first, then call the deleteRecipe
service function to delete the recipe.
Step Detail Component
Right click the "scripts/app/components" folder, add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "step-detail.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { RecipeStep } from "../viewmodels/recipestep";
import { AppService } from "../services/app.service";
@Component({
selector: 'step-detail',
templateUrl: '../partials/editStep.html'
})
export class StepDetailComponent implements OnInit {
item: RecipeStep;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var id = params['id'];
this.AppService.getStep(id).subscribe(item => this.item = item);
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public editRecipeStep() {
this.AppService.saveStep(this.item).subscribe(
item => { this.item = item; this.router.navigate(['/recipes']); },
error => console.log(error)
)
}
}
In this class, we call the getStep
service function to get recipe step information first, then call saveStep
service function to update recipe step.
Step New Component
Right click the "scripts/app/components" folder, and add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "step-new.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { RecipeStep } from "../viewmodels/recipeStep";
import { AppService } from "../services/app.service";
@Component({
selector: 'step-new',
templateUrl: '../partials/addStep.html'
})
export class StepNewComponent implements OnInit {
item: RecipeStep;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var parentId = params['id'];
this.item = new RecipeStep();
this.item.parentId = parentId;
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public addRecipeStep() {
this.AppService.saveStep(this.item).subscribe(
item => { this.item = item; this.router.navigate(['/recipes']);},
error => console.log(error)
)
}
}
In this class, we create a new step first, then call the saveStep
service function to add a recipe step.
Step Delete Component
Right click the "scripts/app/components" folder, and add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "step-delete.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { RecipeStep } from "../viewmodels/recipeStep";
import { AppService } from "../services/app.service";
@Component({
selector: 'step-delete',
templateUrl: '../partials/deleteStep.html'
})
export class StepDeleteComponent implements OnInit {
item: RecipeStep;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var id = params['id'];
this.AppService.getStep(id).subscribe(item => this.item = item);
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public deleteStep() {
this.AppService.deleteStep(this.item.id).subscribe(
() => this.router.navigate(['/recipes']),
error => console.log(error)
)
}
}
In this class, we call the getStep
service function to get recipe step information first, then call the deleteStep
service function to delete recipe step.
Item Detail Component
Right click the "scripts/app/components" folder, and add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "item-detail.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { RecipeItem } from "../viewmodels/recipeitem";
import { AppService } from "../services/app.service";
@Component({
selector: 'item-detail',
templateUrl: '../partials/editItem.html'
})
export class ItemDetailComponent implements OnInit {
item: RecipeItem;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var id = params['id'];
this.AppService.getItem(id).subscribe(item => this.item = item);
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public editRecipeItem() {
this.AppService.saveItem(this.item).subscribe(
item => { this.item = item; this.router.navigate(['/recipes']); },
error => console.log(error)
)
}
}
In this class, we call the getItem service function to get recipe item information first, then call saveItem service function to update recipe item.
Item New Component
Right click the "scripts/app/components" folder, and add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "item-new.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { RecipeItem } from "../viewmodels/recipeItem";
import { AppService } from "../services/app.service";
@Component({
selector: 'item-new',
templateUrl: '../partials/addItem.html'
})
export class ItemNewComponent implements OnInit {
item: RecipeItem;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var parentId = params['id'];
this.item = new RecipeItem();
this.item.parentId = parentId;
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public addRecipeItem() {
this.AppService.saveItem(this.item).subscribe(
item => { this.item = item; this.router.navigate(['/recipes']);},
error => console.log(error)
)
}
}
In this class, we create a new item first, then call the saveItem
service function to add a recipe item.
Item Delete Component
Right click "scripts/app/components" folder, add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "item-delete.component.ts".
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Router, ActivatedRoute } from "@angular/router";
import { RecipeItem } from "../viewmodels/recipeItem";
import { AppService } from "../services/app.service";
@Component({
selector: 'item-delete',
templateUrl: '../partials/deleteItem.html'
})
export class ItemDeleteComponent implements OnInit {
item: RecipeItem;
sub: any;
constructor(private AppService: AppService, private router: Router, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
var id = params['id'];
this.AppService.getItem(id).subscribe(item => this.item = item);
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
public deleteItem() {
this.AppService.deleteItem(this.item.id).subscribe(
() => this.router.navigate(['/recipes']),
error => console.log(error)
)
}
}
In this class, we call the getItem
service function to get the recipe step information first, then call deleteItem
service function to delete the recipe item.
Change App Module
An Angular module class describes how the application parts fit together. Every application has at least one Angular module, the root module that you bootstrap to launch the application. You can call it anything you want. So we load all components created.
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { HttpModule } from "@angular/http";
import { RouterModule } from "@angular/router";
import { FormsModule } from "@angular/forms";
import "rxjs/Rx";
import { AppComponent } from "./app.component";
import { RecipeListComponent } from "./components/recipe-list.component";
import { RecipeDetailComponent } from "./components/recipe-detail.component";
import { RecipeNewComponent } from "./components/recipe-new.component";
import { RecipeDeleteComponent } from "./components/recipe-delete.component";
import { StepDetailComponent } from "./components/step-detail.component";
import { StepNewComponent } from "./components/step-new.component";
import { StepDeleteComponent } from "./components/step-delete.component";
import { ItemDetailComponent } from "./components/item-detail.component";
import { ItemNewComponent } from "./components/item-new.component";
import { ItemDeleteComponent } from "./components/item-delete.component";
import { AppRouting } from "./app.routing";
import { AppService } from "./services/app.service";
@NgModule({
declarations: [
AppComponent,
RecipeListComponent,
RecipeDetailComponent,
RecipeNewComponent,
RecipeDeleteComponent,
StepDetailComponent,
StepNewComponent,
StepDeleteComponent,
ItemDetailComponent,
ItemNewComponent,
ItemDeleteComponent,
],
imports: [
BrowserModule,
HttpModule,
FormsModule,
RouterModule,
AppRouting
],
providers: [
AppService
],
bootstrap: [
AppComponent
]
})
export class AppModule { }
Also, we import route module here. Then we can do a route configuration.
Client Route Configuration
A routed Angular application has one singleton instance of the Router service. When the browser's URL changes, that router looks for a corresponding Route from which it can determine the component to display.
A router has no routes until you configure it. We configure our client routing in app.routing.ts.
Right click the "scripts/app/components" folder, and add a new item. Select ".Net Core/Client-Side" TypeScript File. Give the name "app.routing.ts".
import { ModuleWithProviders } from "@angular/core";
import { Routes, RouterModule } from "@angular/router";
import { RecipeListComponent } from "./components/recipe-list.component";
import { RecipeDetailComponent } from "./components/recipe-detail.component";
import { RecipeNewComponent } from "./components/recipe-new.component";
import { RecipeDeleteComponent } from "./components/recipe-delete.component";
import { StepDetailComponent } from "./components/step-detail.component";
import { StepNewComponent } from "./components/step-new.component";
import { StepDeleteComponent } from "./components/step-delete.component";
import { ItemDetailComponent } from "./components/item-detail.component";
import { ItemNewComponent } from "./components/item-new.component";
import { ItemDeleteComponent } from "./components/item-delete.component";
const routes: Routes = [
{
path: '',
redirectTo: '/recipes',
pathMatch: 'full'
},
{
path: 'recipes',
component: RecipeListComponent
},
{
path: 'recipes/edit/:id',
component: RecipeDetailComponent
},
{
path: 'recipes/add',
component: RecipeNewComponent
},
{
path: 'recipes/delete/:id',
component: RecipeDeleteComponent
},
{
path: 'recipes/editStep/:id',
component: StepDetailComponent
},
{
path: 'recipes/addStep/:id',
component: StepNewComponent
},
{
path: 'recipes/deleteStep/:id',
component: StepDeleteComponent
},
{
path: 'recipes/editItem/:id',
component: ItemDetailComponent
},
{
path: 'recipes/addItem/:id',
component: ItemNewComponent
},
{
path: 'recipes/deleteItem/:id',
component: ItemDeleteComponent
},
];
export const AppRoutingProviders: any[] = [
];
export const AppRouting: ModuleWithProviders = RouterModule.forRoot(routes);
Here we configure all the paths and components in an array, then app module imports this array.
Recipe List Template (Repcipes.html)
<div>
<a routerLink="/recipes/add" class="btn breadcrumb m-2">create a new recipe</a>
<div *ngFor="let recipe of items">
<div class="btn-group tab-pane mb-2">
<button class="btn-info pull-left" (click)="Expand(recipe)"><h5>{{recipe.name}} - {{recipe.comments}}</h5></button>
</div>
<div class="btn-group">
<a routerLink="/recipes/edit/{{recipe.id}}" class="breadcrumb-item">edit</a>
<a routerLink="/recipes/delete/{{recipe.id}}" class="breadcrumb-item">delete</a>
</div>
<div *ngIf="recipe.show">
<a routerLink="/recipes/addStep/{{recipe.id}}" class="btn breadcrumb m-2">create a new step</a>
<div *ngFor="let step of recipe.recipeSteps">
<div class="row ml-2">
<div class="breadcrumb ml-2">
<span>step {{step.stepNo}} : {{step.instructions}}</span>
</div>
<div class="btn-group m-2">
<a routerLink="/recipes/editStep/{{step.id}}" class="breadcrumb-item">edit</a>
<a routerLink="/recipes/deleteStep/{{step.id}}" class="breadcrumb-item">delete</a>
</div>
</div>
<a routerLink="/recipes/addItem/{{step.id}}" class="btn breadcrumb ml-4">create a new item</a>
<div *ngFor="let item of step.recipeItems">
<div class="row ml-4">
<div class="card-text ml-4">
<p> {{item.name}} {{item.quantity}} {{item.measurementUnit}}</p>
</div>
<div class="btn-group ml-2">
<a routerLink="/recipes/editItem/{{item.id}}" class="breadcrumb-item">edit</a>
<a routerLink="/recipes/deleteItem/{{item.id}}" class="breadcrumb-item">delete</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
The RouterLink
directives on the anchor tags give the router control over those elements. The navigation paths are fixed, so you can assign a string to the routerLink (a "one-time" binding).
Had the navigation path been more dynamic, you could have bound to a template expression that returned an array of route link parameters (the link parameters array). The router resolves that array into a complete URL.
In the Recipe List template we have both fixed link and dynamic link. I use ngIf="recipe.show"
to expand or collapse the corresponding recipe. One thing I have to mention, for all edit and delete functions we pass the object id, but create a new step and new item, we need to pass the parent object id. That means creating a new step, we need pass recipe id; create a new item, then we need pass step id. Obviously we don’t need pass anything to create a new recipe.
Recipe Detail Template (edit.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "edit.html".
<div class="badge badge-info">
<h4>Edit Recipe</h4>
</div>
<div *ngIf="item" class="card-text">
<form (ngSubmit)="editRecipe()">
<div class="row">
<div class="col-xl-6 form-group">
<label for="name">Name</label>
<input [(ngModel)]="item.name" name="name" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="comments">Comments</label>
<input [(ngModel)]="item.comments" name="comments" type="text" class="form-control" />
</div>
</div>
<div class="row m-2">
<button type="submit" class="btn btn-primary">Save</button>
<a routerLink="/recipes" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
Recipe Details Template actually is a submit form. However, ngSubmit
ensures that the form doesn’t submit when the handler code throws (which is the default behaviour of submit) and causes an actual http post request.
In order to register form controls, we use the ngModel
directive. In combination with a name attribute, ngModel
creates a form control abstraction for us behind the scenes. Every form control that is registered with ngModel
will automatically show up in form.value and can then easily be used for further post processing.
In this template, ngSubmit
binding with eidtRecipe()
method in Recipe Detail Component. "Cancel" button just comes back to the list.
Recipe New Template (add.html)
Right click "wwwroot/partials" folder, add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "add.html".
<div class="badge badge-info">
<h4>Add Recipe</h4>
</div>
<div *ngIf="item" class="card-text">
<form (ngSubmit)="addRecipe()">
<div class="row">
<div class="col-xl-6 form-group">
<label for="name">Name</label>
<input [(ngModel)]="item.name" name="name" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="comments">Comments</label>
<input [(ngModel)]="item.comments" name="comments" type="text" class="form-control" />
</div>
</div>
<div class="row m-2">
<button type="submit" class="btn btn-primary">Save</button>
<a routerLink="/recipes" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
In this template, ngSubmit
binding with addRecipe()
method in Recipe New Component. The "Cancel" button just comes back to the list.
Recipe Delete Template (delete.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "delete.html".
<div *ngIf="item">
<div class="row">
<div class="alert alert-warning">
<p>Do you really want to delete this recipe?</p>
<p> {{item.name}} - {{item.comments}}</p>
</div>
</div>
<button (click)="deleteRecipe()" class="btn btn-danger">Yes</button>
<a routerLink="/recipes" class="btn btn-default">No</a>
</div>
Recipe Delete template is not a submit form. The "Yes" button calls deleteRecipe()
of Recipe Delete Component directly. The "No" button navigates back to recipe list.
Step Detail Template (editStep.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "editStep.html".
<div class="badge badge-info">
<h4>Edit Recipe Step</h4>
</div>
<div *ngIf="item" class="card-text">
<form (ngSubmit)="editRecipeStep()">
<div class="row">
<div class="col-xl-6 form-group">
<label for="stepNo">Step No.</label>
<input [(ngModel)]="item.stepNo" name="stepNo" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="instructions">Instructions</label>
<input [(ngModel)]="item.instructions" name="instructions" type="text" class="form-control" />
</div>
</div>
<div class="row m-2">
<button type="submit" class="btn btn-primary">Save</button>
<a routerLink="/recipes" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
In this template, ngSubmit
is binding with editRecipeStep()
method in Step Detail Component. The "Cancel" button just comes back to the list.
Step New Template (addStep.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "addStep.html".
<div class="badge badge-info">
<h4>Add a new recipe Step</h4>
</div>
<div *ngIf="item" class="card-text">
<form (ngSubmit)="addRecipeStep()">
<div class="row">
<div class="col-xl-6 form-group">
<label for="stepNo">Step No.</label>
<input [(ngModel)]="item.stepNo" name="stepNo" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="instructions">Instructions</label>
<input [(ngModel)]="item.instructions" name="instructions" type="text" class="form-control" />
</div>
</div>
<div class="row m-2">
<button type="submit" class="btn btn-primary">Save</button>
<a routerLink="/recipes" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
In this template, ngSubmit is binding with addRecipeStep() method in Step New Component. "Cancel" button just comes back to the list.
Step Delete Template (deleteStep.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "deleteStep.html".
<div *ngIf="item">
<div class="row">
<div class="alert alert-warning">
<p>Do you really want to delete this recipe step?</p>
<p>Step {{item.stepNo}} - {{item.instructions}}</p>
</div>
</div>
<button (click)="deleteStep()" class="btn btn-danger">Yes</button>
<a routerLink="/recipes" class="btn btn-default">No</a>
</div>
The Step Delete template is not a submit form. The "Yes" button calls deleteStep()
of Step Delete Component directly. The "No" button navigates back to recipe list.
Item Detail Template (editItem.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "editItem.html".
<div class="badge badge-info">
<h4>Edit Recipe Item</h4>
</div>
<div *ngIf="item" class="card-text">
<form (ngSubmit)="editRecipeItem()">
<div class="row">
<div class="col-xl-6 form-group">
<label for="name">Name</label>
<input [(ngModel)]="item.name" name="name" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="quantity">Quantity</label>
<input [(ngModel)]="item.quantity" name="quantity" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="measurementUnit">Measurement Unit</label>
<input [(ngModel)]="item.measurementUnit" name="measurementUnit" type="text" class="form-control" />
</div>
</div>
<div class="row m-2">
<button type="submit" class="btn btn-primary">Save</button>
<a routerLink="/recipes" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
In this template, ngSubmit
is binding with the editRecipeItem()
method in Item Detail Component. The "Cancel" button just comes back to the list.
Item New Template (addItem.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "addItem.html".
<div class="badge badge-info">
<h4>Add a new recipe Item</h4>
</div>
<div *ngIf="item" class="container-fluid">
<form (ngSubmit)="addRecipeItem()">
<div class="row">
<div class="col-xl-6 form-group">
<label for="name">Name</label>
<input [(ngModel)]="item.name" name="name" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="quantity">Quantity</label>
<input [(ngModel)]="item.quantity" name="quantity" type="text" class="form-control" />
</div>
</div>
<div class="row">
<div class="col-xl-6 form-group">
<label for="measurementUnit">Measurement Unit</label>
<input [(ngModel)]="item.measurementUnit" name="measurementUnit" type="text" class="form-control" />
</div>
</div>
<div class="row m-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="/" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
In this template, ngSubmit
is binding with the addRecipeItem()
method in Item New Component. The "Cancel" button just comes back to the list.
Item Delete Template (deleteItem.html)
Right click the "wwwroot/partials" folder, and add a new item. Select ".Net Core/Client-Side" HTML Page. Give the name "deleteItem.html".
<div *ngIf="item">
<div class="row">
<div class="alert alert-warning">
<p>Do you really want to delete this recipe item?</p>
<p> {{item.name}} {{item.quantity}} {{item.measurementUnit}}</p>
</div>
</div>
<button (click)="deleteItem()" class="btn btn-danger">Yes</button>
<a routerLink="/recipes" class="btn btn-default">No</a>
</div>
Item Delete template is not a submit form. "Yes" button calls deleteItem()
of Item Delete Component directly. The "No" button navigates back to the recipe list.
Adding the base tag
We need to set the base tag as it will tell the routing engine how to compose all of the upcoming navigation URLs our app will eventually have.
We add the base tag in our index.html, which is under the wwwroot folder.
<html>
<head>
<base href="/">
<title>Master Chef2</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
<!--
<script src="js/shim.min.js"></script>
<script src="js/zone.js"></script>
<script src="js/Reflect.js"></script>
<script src="js/system.src.js"></script>
<!--
<script src="/js/moment.js"></script>
<!--
<script src="systemjs.config.js"></script>
<script>
System.import('app').catch(function (err) { console.error(err); });
</script>
<link href="lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" media="screen">
</head>
<!--
<body>
<div class="container-fluid">
<!--
<masterchef2>Please wait...</masterchef2>
</div>
</body>
</html>
Angular 2 Typescript Cannot Find Names
When I build the solution, I get a lot of compile errors. For example, error TS2304: Build:Cannot find name 'Promise'.
There are two ways to fix it.
- Switch the transpiler’s target from ES5 to ES6. To do that, change your tsconfig.json file to match the following values:
{
"compileOnSave": false,
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "system",
"moduleResolution": "node",
"noImplicitAny": false,
"noEmitOnError": false,
"removeComments": false,
"sourceMap": true,
"target": "es6"
},
"exclude": [
"node_modules",
"wwwroot"
]
}
However, doing that could bring in some issues: you could be unable to use some of your tools/packages/libraries who don’t support ES6 yet, such as UglifyJS.
- Install Typings and core-js Type Definition Files. The tanspiller’s target is still ES5.
{
"compileOnSave": false,
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "system",
"moduleResolution": "node",
"noImplicitAny": false,
"noEmitOnError": false,
"removeComments": false,
"sourceMap": true,
"target": "es5"
},
"exclude": [
"node_modules",
"wwwroot"
]
}
Open the package.json file (the one enumerating the NPM packages) and check if the typings package is already present within the dependencies or devDependencies
node, together with the script required to run it during the post-install phase within the script block. If they’re not here, add them so that your file should look like the following:
{
"version": "1.0.0",
"name": "asp.net",
"dependencies": {
"@angular/common": "2.0.0",
"@angular/compiler": "2.0.0",
"@angular/core": "2.0.0",
"@angular/forms": "2.0.0",
"@angular/http": "2.0.0",
"@angular/platform-browser": "2.0.0",
"@angular/platform-browser-dynamic": "2.0.0",
"@angular/router": "3.0.0",
"@angular/upgrade": "2.0.0",
"core-js": "^2.4.1",
"reflect-metadata": "^0.1.8",
"rxjs": "5.0.0-rc.4",
"systemjs": "^0.19.41",
"typings": "^1.3.2",
"zone.js": "^0.7.2",
"moment": "^2.17.0"
},
"devDependencies": {
"gulp": "^3.9.1",
"gulp-clean": "^0.3.2",
"gulp-concat": "^2.6.1",
"gulp-less": "^3.3.0",
"gulp-sourcemaps": "^1.9.1",
"gulp-typescript": "^3.1.3",
"gulp-uglify": "^2.0.0",
"typescript": "^2.0.10"
},
"scripts": {
"postinstall": "typings install dt~core-js@^0.9.7 --global"
}
}
Please note we have to specify the version "0.9.7", otherwise it will install the latest version which is still cause problems. Now the ES6 TypeScript packages should compile without issues.
Run Application
First, rebuild the solution. Then go to the Tasks Runner Explorer window to run default task.
After all tasks finished, Click "IIS Express".
Add a new recipe, Mapo Tofu.
After you save, you can add steps and items for each step.
Debug Angular Code in Google Chrome
Although Angular 2 is TypeScript, all TypeScript files are converted to JavaScript minify files by gulp task. Look the below screenshot, the corresponding JavaScript files are created under the wwwroot/app folder.
So you cannot debug TypeScript directly. Fortunately, we can debug JavaScript files instead.
Click "IIS Express" dropdown button, select Google Chrome for the web browser. Then start the application by clicking "IIS Express". After the application starts, click "Developer Tools" from "More tools" in Google Chrome. Then click "Source." Now you can see all JavaScript files with a tree view. Pick up any JavaScript file you want to debug. Here we take deleting recipe as an example. So I pickup "recipe-delete.component.js".
As I said, all JavaScript files are created with minify style, which is very hard to read. But don’t worry, Chrome can restore the minify file to normal file for you. Just click "{}" which is at the left bottom of the middle window, the minify file will be changed to the "pretty print" file. I put the break pint at the deleteRecipe()
function.
Click "delete" beside the recipe. Application shows Recipe Delete template.
Then click "Yes" to trigger the break point, and you can watch the variable you’re interested.
Put the break point at deleteRecipe
function of the app.service.js. Then click "Resume script" button or press F8, the break point at app.service.js is triggered as well.
From App Service, it calls the server side web API. If you put the break point at the server side Http Delete method, server side break point will be triggered when you resume the script.
Conclusion
In these articles, I have showed you how to build an Angular 2 CRUD SPA within the context of ASP.NET Core. We have also learned how to use Angular 2 Route to navigate to different components and templates with client side routing configuration. Regarding Angular 4 has been released in March, Master Chef will be moved to Visual Studio 2017 and Angular 4 in the next article.
I've created a public repository in github, Master Chef Repository. Please feel free to participate the development.