Introduction
This is the first of a series of articles to show how to create an application framework from scratch that uses both ASP.NET Core and Angular 2 together, using the best features of each. This method is relatively easy to use, scales well across small or large teams, and performs well for normal websites as well as multiple tenanted sites.
Applications using both ASP.NET Core and Angular 2 have been done before, but tend to either (a) barely use ASP.NET Core - just serving data using Web API and serving 'flat' HTML files as Angular templates, else (b) go to the other extreme of complexity, using ASP.NET Core with Webpack to pre-render Angular 2 on the server.
I've tried both, but found the simpler option (a) can tempt team members to cut and paste client side markup instead of creating common directives, as well as more fragile code with much less than ideal coupling. The more complex option (b) simply makes many developers' heads spin (junior and senior alike) and can take a lot of work to set up and debug. In both cases, many of the great features of ASP.NET, tag helpers, MVC views and Razor are left unused.
This framework uses ASP.NET MVC views, Razor, and tag helpers to generate client side HTML, CSS markup, validation, Angular code and Angular views all from the types and attributes on properties of your server side view model. Last of all, once the Web API data component are created, we'll use Swagger tools to create Angular 2 data services and data models, leaving you the relatively simple task of creating the Angular components to link these all together.
For these articles, I'll assume you know a little about ASP.NET MVC, general C#, HTML, Bootstrap and Angular, and concentrate on brief details on how to use these together. If you need in depth information, there are some very good tutorials here on Code Project as well as on training sites such as Pluralsight.
I've used the techniques here over the last two years in a number of commercial projects and in a wide variety of business domains. These articles take these concepts and move from .NET 4.5x and Angular 1.x to use .NET Core and Angular 2.
TLDR - for the final source, see Github at https://github.com/RobertDyball/a2spa.
(1) Create an ASP.NET Core App
To create our ASP.NET + Angular SPA we will start by creating a standard Visual Studio 2015 ASP.NET Core template, styled using Bootstrap 3.
In Visual Studio 2015, click File, New, Project.
After File, New, Project, select Templates, then ASP.NET Core:
Provide the name (I used A2SPA
), the path or Location (I used c:\dev\), I like to leave "Create directory for solution" checked, and also I check "Create new GIT repository".
Select Web Application, leave Authentication as "No Authentication", then click OK.
You should now see the solution read me file, and a new solution. Click Ctrl-F5 on your keyboard, and you should build the solution, launch IIS Express and your default browser, and if all is well, see something like this, below, in your browser:
(2) Update ASP.NET Core from Version 1.0.1 to 1.1.0
Next, we'll update our base ASP.NET Core solution from version 1.0.1 to version 1.1.0, although by the time this is out there, there's likely to be another version, the general method should likely be similar. Have a look at the ASP.NET blogs here for details of version 1.0.1 to 1.1.0.
Open global.json, change from:
{
"projects": [ "src", "test" ],
"sdk": {
"version": "1.0.0-preview2-003131"
}
}
to the following, to use the latest / current ASP.NET Core SDK:
{
"projects": [ "src", "test" ],
"sdk": {
"version": "1.0.0-preview2-1-003177"
}
}
Open project.json and change the part of the file where it has this:
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.0.1",
"type": "platform"
},
To read the following, this version 1.1.0 entry corresponds to the SDK version "1.0.0-preview2-003177
" immediately above:
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.1.0",
"type": "platform"
},
and lastly, same file, look for this:
"frameworks": {
"netcoreapp1.0": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
}
},
and change it to this, agani to target the new framework 1.1 instead of the earlier 1.0 framework:
"frameworks": {
"netcoreapp1.1": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
}
},
Next, we'll upgrade the packages using NuGet, right click the solution within the project, then click "Manage NuGet Packages".
Select Updates
Check "Select All Packages" then click the Update button to begin the update:
Click Agree to proceed:
Wait to download the updates, then for update to finish:
Finally, rebuild, hit ctrl-F5 and you have a successful build and should see the app again, still running:
Package.json prior to the upgrade was:
{
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.0.1",
"type": "platform"
},
"Microsoft.AspNetCore.Diagnostics": "1.0.0",
"Microsoft.AspNetCore.Mvc": "1.0.1",
"Microsoft.AspNetCore.Razor.Tools": {
"version": "1.0.0-preview2-final",
"type": "build"
},
"Microsoft.AspNetCore.Routing": "1.0.1",
"Microsoft.AspNetCore.Server.IISIntegration": "1.0.0",
"Microsoft.AspNetCore.Server.Kestrel": "1.0.1",
"Microsoft.AspNetCore.StaticFiles": "1.0.0",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0",
"Microsoft.Extensions.Configuration.Json": "1.0.0",
"Microsoft.Extensions.Logging": "1.0.0",
"Microsoft.Extensions.Logging.Console": "1.0.0",
"Microsoft.Extensions.Logging.Debug": "1.0.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0",
"Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0"
},
"tools": {
"BundlerMinifier.Core": "2.0.238",
"Microsoft.AspNetCore.Razor.Tools": "1.0.0-preview2-final",
"Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.0.0-preview2-final"
},
"frameworks": {
"netcoreapp1.0": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
}
},
"buildOptions": {
"emitEntryPoint": true,
"preserveCompilationContext": true
},
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
}
},
"publishOptions": {
"include": [
"wwwroot",
"**/*.cshtml",
"appsettings.json",
"web.config"
]
},
"scripts": {
"prepublish": [ "bower install", "dotnet bundle" ],
"postpublish": [ "dotnet publish-iis
--publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ]
}
}
For reference, the final package.json should now (give or take) look like this:
{
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.1.0",
"type": "platform"
},
"BundlerMinifier.Core": "2.2.306",
"Microsoft.AspNetCore.Diagnostics": "1.1.0",
"Microsoft.AspNetCore.Mvc": "1.1.0",
"Microsoft.AspNetCore.Razor.Tools": "1.1.0-preview4-final",
"Microsoft.AspNetCore.Routing": "1.1.0",
"Microsoft.AspNetCore.Server.IISIntegration": "1.1.0",
"Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.1.0-preview4-final",
"Microsoft.AspNetCore.Server.Kestrel": "1.1.0",
"Microsoft.AspNetCore.StaticFiles": "1.1.0",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "1.1.0",
"Microsoft.Extensions.Configuration.Json": "1.1.0",
"Microsoft.Extensions.Logging": "1.1.0",
"Microsoft.Extensions.Logging.Console": "1.1.0",
"Microsoft.Extensions.Logging.Debug": "1.1.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "1.1.0",
"Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.1.0"
},
"tools": {
},
"frameworks": {
"netcoreapp1.1": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
}
},
"buildOptions": {
"emitEntryPoint": true,
"preserveCompilationContext": true
},
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
}
},
"publishOptions": {
"include": [
"wwwroot",
"**/*.cshtml",
"appsettings.json",
"web.config"
]
},
"scripts": {
"prepublish": [ "bower install", "dotnet bundle" ],
"postpublish": [ "dotnet publish-iis
--publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ]
}
}
(3) Adding Angular 2 QuickStart into our ASP.NET Core App
We'll be using the Angular 2 QuickStart app from the source on GitHub, here.
If you need some further background, work through the excellent tutorials from the Angular 2 team here.
To get the Angular 2 QuickStart code merged into our ASP.NET Core MVC app, we'll start by getting a copy of the source.
There are a few ways to do this; you could download a ZIP of the latest source from GitHub, or you could clone a complete copy of the repository using Git Extensions or your favourite Git utility.
The command is the same, irrespective of where you do it, in my case, I typed this into Powershell:
git clone https://github.com/angular/quickstart
This is the result:
Next navigate to the folder containing the files cloned above.
Copy the app folder (both folder and files in the folder) as well as these files: index.html, systemjs.config.extras.js and system.config.js to the VS2015 solution's wwwroot folder.
In addition, copy the files package.json, tsconfig.json and tslint.json to the VS2015 project's root directory.
Prior to copying, your VS2015 project should look like this:
If you copy files from QuickStart using Windows explorer, you can paste the files directly into the project using VS2015 solution explorer. You should see this when completed:
NOTE: The warning on missing dependencies is simply due to the presence of the new package.json file. Additionally in VS2015, the file systemjs.config.extras.js will be collapsed under the system.config.js - even if you copy them separately. It should be there in the wwwroot folder, and you can easily verify this by checking it.
Lastly, before you build, edit the package.json file to remove packages no longer needed.
Before editing, the package.json file should look like this, below, though the exact content may vary over time as Angular 2 progresses versions and other changes.
Before:
{
"name": "angular-quickstart",
"version": "1.0.0",
"description": "QuickStart package.json from the documentation,
supplemented with testing support",
"scripts": {
"start": "tsc && concurrently \"tsc -w\" \"lite-server\" ",
"e2e": "tsc && concurrently \"http-server -s\"
\"protractor protractor.config.js\" --kill-others --success first",
"lint": "tslint ./app/**/*.ts -t verbose",
"lite": "lite-server",
"pree2e": "webdriver-manager update",
"test": "tsc && concurrently \"tsc -w\" \"karma start karma.conf.js\"",
"test-once": "tsc && karma start karma.conf.js --single-run",
"tsc": "tsc",
"tsc:w": "tsc -w"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@angular/common": "~2.4.0",
"@angular/compiler": "~2.4.0",
"@angular/core": "~2.4.0",
"@angular/forms": "~2.4.0",
"@angular/http": "~2.4.0",
"@angular/platform-browser": "~2.4.0",
"@angular/platform-browser-dynamic": "~2.4.0",
"@angular/router": "~3.4.0",
"angular-in-memory-web-api": "~0.2.4",
"systemjs": "0.19.40",
"core-js": "^2.4.1",
"rxjs": "5.0.1",
"zone.js": "^0.7.4"
},
"devDependencies": {
"concurrently": "^3.1.0",
"lite-server": "^2.2.2",
"typescript": "~2.0.10",
"canonical-path": "0.0.2",
"http-server": "^0.9.0",
"tslint": "^3.15.1",
"lodash": "^4.16.4",
"jasmine-core": "~2.4.1",
"karma": "^1.3.0",
"karma-chrome-launcher": "^2.0.0",
"karma-cli": "^1.0.1",
"karma-jasmine": "^1.0.2",
"karma-jasmine-html-reporter": "^0.2.2",
"protractor": "~4.0.14",
"rimraf": "^2.5.4",
"@types/node": "^6.0.46",
"@types/jasmine": "^2.5.36"
},
"repository": {}
}
After:
{
"dependencies": {
"@angular/common": "~2.4.0",
"@angular/compiler": "~2.4.0",
"@angular/core": "~2.4.0",
"@angular/forms": "~2.4.0",
"@angular/http": "~2.4.0",
"@angular/platform-browser": "~2.4.0",
"@angular/platform-browser-dynamic": "~2.4.0",
"@angular/router": "~3.4.0",
"angular-in-memory-web-api": "~0.2.4",
"systemjs": "0.19.40",
"core-js": "^2.4.1",
"rxjs": "5.0.1",
"zone.js": "^0.7.4"
},
"devDependencies": {
"typescript": "~2.0.10",
"tslint": "^3.15.1"
},
"repository": {}
}
As you hit Save, after editing, you will likely see VS2015 restoring the packages:
And then when finished, no more warnings beside dependencies:
Next we'll edit tsconfig.json, from this:
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"lib": [ "es2015", "dom" ],
"noImplicitAny": true,
"suppressImplicitAnyIndexErrors": true
}
}
to this:
{
"compilerOptions": {
"diagnostics": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"lib": [ "es2015", "dom" ],
"listFiles": true,
"module": "commonjs",
"moduleResolution": "node",
"noImplicitAny": true,
"outDir": "wwwroot",
"removeComments": false,
"rootDir": "wwwroot",
"sourceMap": true,
"suppressImplicitAnyIndexErrors": true,
"target": "es5"
},
"exclude": [
"node_modules"
]
}
Last of all, before we build, we have one typescript file to remove.
Go to the app folder under wwwroot and delete app.component.spec.ts:
Since we have not included the tests in this project, nor the dependencies, so it will create errors if left in place.
When the build is completed, you should find the typescript files automatically "transpiled" in-place, and when unfolded, reveal a JavaScript .js file as well as a map .js.map file for each.
The .js JavaScript files will be executed in the browser, the .js.map files are used if and when you are debugging to link an issue in JavaScript back to the typescript source.
To test the build, hit Ctrl-F5 to compile and launch the browser, you should still see a working MVC page.
At the default URL:
As well as at /home/index:
To see the quickstart page, edit the URL to look at /index.html.
You should see the Angular loader:
But you will not see anything else, yet. Hit F12 and you should see Angular's error message:
Which when unfolded is likely not all too helpful either.
The reason is that none of the JavaScript libraries required are served from their current location. Look inside index.html and you see:
<!DOCTYPE html>
<html>
<head>
<title>Angular QuickStart</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="styles.css">
<!--
<script src="node_modules/core-js/client/shim.min.js"></script>
<script src="node_modules/zone.js/dist/zone.js"></script>
<script src="node_modules/systemjs/dist/system.src.js"></script>
<script src="systemjs.config.js"></script>
<script>
System.import('app').catch(function(err){ console.error(err); });
</script>
</head>
<body>
<my-app>Loading AppComponent content here ...</my-app>
</body>
</html>
The library files would be expected to be served from wwwroot/node_modules but they are actually under this, in the project root. This is not obvious unless you turn on the show all files option:
Then you will see the folder appear, greyed out to indicate it is not source controlled or part of the solution, but added separately as required.
You can re-hide the folders, as they can get distracting, and now we'll fix this issue.
We'll add a package Microsoft.AspNetCore.SpaServices
using NuGet.
Right click the project root, click Manage NuGet Packages, select Browse, ensure you have pre-release checked:
Click on the package:
Then:
After install, don't be fooled by the Update button (look closely, you will see it is a downgrade).
The important thing is that this aspnet core library is now available to use. Look at project.json and you will see the new package added there:
{
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.1.0",
"type": "platform"
},
"BundlerMinifier.Core": "2.2.306",
"Microsoft.AspNetCore.Diagnostics": "1.1.0",
"Microsoft.AspNetCore.Mvc": "1.1.0",
"Microsoft.AspNetCore.Razor.Tools": "1.1.0-preview4-final",
"Microsoft.AspNetCore.Routing": "1.1.0",
"Microsoft.AspNetCore.Server.IISIntegration": "1.1.0",
"Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.1.0-preview4-final",
"Microsoft.AspNetCore.Server.Kestrel": "1.1.0",
"Microsoft.AspNetCore.StaticFiles": "1.1.0",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "1.1.0",
"Microsoft.Extensions.Configuration.Json": "1.1.0",
"Microsoft.Extensions.Logging": "1.1.0",
"Microsoft.Extensions.Logging.Console": "1.1.0",
"Microsoft.Extensions.Logging.Debug": "1.1.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "1.1.0",
"Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.1.0",
"Microsoft.AspNetCore.SpaServices": "1.1.0-beta-000002"
},
"tools": {
},
"frameworks": {
"netcoreapp1.1": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
}
},
"buildOptions": {
"emitEntryPoint": true,
"preserveCompilationContext": true
},
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
}
},
"publishOptions": {
"include": [
"wwwroot",
"**/*.cshtml",
"appsettings.json",
"web.config"
]
},
"scripts": {
"prepublish": [ "bower install", "dotnet bundle" ],
"postpublish": [ "dotnet publish-iis
--publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ]
}
}
Next open the file startup.cs in the editor, where we will edit the end of the file, where it has:
...
app.UseStaticFiles();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
}
Change this to:
...
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider
(Path.Combine(env.ContentRootPath, "node_modules")),
RequestPath = "/node_modules"
});
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
routes.MapSpaFallbackRoute("spa-fallback",
new { controller = "home", action = "index" });
});
}
}
}
To satisfy dependencies for PhysicalFileProvider
and Path.Combine
, we need to add this to the using
s at the top of startup.cs:
Before:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace A2SPA
...
After:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.FileProviders;
using System.IO;
namespace A2SPA
...
Some of the default dependency entries are not required, so you can edit these to this final version:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.FileProviders;
using System.IO;
namespace A2SPA
...
Save startup.cs and rebuild, hit Ctrl-F5 and once again browsing to index.html, or the root directory (as the changes above now mean the index.html file will "win" over the /home/index path when browsing the root directory.
Now the app should load correctly:
Importantly, you can still browse the older ASP.NET Core MVC home page at /home/index by using the path explicitly, as below, since we'll be using these controller/action paths more as the project continues.
(4) Using ASP.NET Core Together with Angular
So far, we have two separate applications, the ASP.NET Core MVC app still at /home/index is running as before, and the Angular app launched from index.html.
Many people using ASP.NET Core and Angular JS stop here, they build the Angular website using "flat" HTML, around the index.html (in which case any web server would work), some go further and their integrations will pre-render Angular code at the server and so "bootstrap" data and code to the client, but by pre-rendering data, you now introduce another lot of code to maintain and although these can be useful in high traffic sites that change very little, they can get quite complex.
Other implementations create RESTful web services and use ASP.NET Core Web API to serve data. However, this and the previous options still leave much of ASP.NET Core's capabilities unused.
Alternately, you could use ASP.NET MVC partial views, using conventional MVC controllers and actions to execute backend C# code, use Razor mark-up in your view, and allow use of custom tag helpers and make use of ASP.NET Core server side caching - all of these together can also deliver your Angular page the HTML templates, however the templates are no longer "flat", but part of a pipeline that provides many optional touch-points.
This latter method is the technique we will use, beginning with moving the Angular home page, index.html page into the home controller and index.cshtml view, moving the common shared libraries into shared views, and creating an MVC controller that will deliver partial views and replace inline and external Angular templates.
The next steps will be to add custom tag helpers that will be used to pre-populate these Angular templates with HTML, styles and validation. Data will not be pre-loaded, instead we'll create RESTful services in ASP.NET Core to deliver data to the Angular page when requested from the client in a more conventional manner.
Angular JS quick start index.html starts as:
<!DOCTYPE html>
<html>
<head>
<title>Angular QuickStart</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="styles.css">
<!--
<script src="node_modules/core-js/client/shim.min.js"></script>
<script src="node_modules/zone.js/dist/zone.js"></script>
<script src="node_modules/systemjs/dist/system.src.js"></script>
<script src="systemjs.config.js"></script>
<script>
System.import('app').catch(function(err){ console.error(err); });
</script>
</head>
<body>
<my-app>Loading AppComponent content here ...</my-app>
</body>
</html>
The Angular Quickstart application has its template inline, in app.component.ts as below:
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
template: `<h1>Hello {{name}}</h1>`,
})
export class AppComponent { name = 'Angular'; }
However, the component could also refer to an external HTML template, as shown below, assuming there was an HTML file appComponent.html present beside it:
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
templateUrl: './appComponent.html'
})
export class AppComponent { name = 'Angular'; }
We're going to use the templateUrl
syntax, similar to the second example above, to point to a new controller PartialController.cs and create a view to deliver what we need.
Create the Partial Controller in the /controllers folder, ensure the file is called PartialController.cs and contains the following code:
using Microsoft.AspNetCore.Mvc;
namespace A2SPA.Controllers
{
public class PartialController : Controller
{
public IActionResult AboutComponent() => PartialView();
public IActionResult AppComponent() => PartialView();
public IActionResult ContactComponent() => PartialView();
public IActionResult IndexComponent() => PartialView();
}
}
This new controller will be used to deliver the HTML templates or views to our client side Angular components.
Next, create a folder called Partial in the /Viewsfolder beside /Views/Home where our new views will be put:
Next, copy the three existing ASP.NET MVC files, About.cshtml, Contact.cshtml and Index.cshtml from /views/home folder to the new /views/partial folder.
Once copied, rename the copied files that are in /home/partial to AboutComponent.cshtml, ContactComponent.cshtml and IndexComponent.cshtml.
We can now clean up our homecontroller.cs, removing methods we no longer need, and to avoid missing pages or confusion in routing.
So where HomeController.cs was:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace A2SPA.Controllers
{
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
public IActionResult About()
{
ViewData["Message"] = "Your application description page.";
return View();
}
public IActionResult Contact()
{
ViewData["Message"] = "Your contact page.";
return View();
}
public IActionResult Error()
{
return View();
}
}
}
Change it to be:
using Microsoft.AspNetCore.Mvc;
namespace A2SPA.Controllers
{
public class HomeController : Controller
{
public IActionResult Index()
{
ViewData["Title"] = "Home";
return View();
}
public IActionResult Error()
{
return View();
}
}
}
Normally, each ASP.NET MVC view shares a lot of common code with /views/shared/_layout.cshtml, each view (index.cshtml, about.cstml, contacts.cshtml) are shown with the content pushed inside the shared layout where it is marked with the Razor code @RenderBody()
as shown in the following excerpt from_layout.cshtml:
...
<div class="container body-content">
@RenderBody()
<hr />
...
In this new project, we'll still use /views/home/index and /views/shared/_layout to deliver content, however as Angular 2 takes over on the client side, the /views/partial/AppComponent.cshtml view will take on some of the tasks that were formerly the job of _layout.cshtml file, as the AppComponent.cshtml view will be used to load other Angular views.
Update /views/partial/AppComponent.cshtml to the following, to enable menu linking using Angular 2 routing:
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header" (click)="setTitle('Home - A2SPA')">
<button type="button" class="navbar-toggle" data-toggle="collapse"
data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a routerLink="/home" routerLinkActive="active" class="navbar-brand">A2SPA</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li>
<a class="nav-link" (click)="setTitle('Home - A2SPA')"
routerLink="/home" routerLinkActive="active">Home</a>
</li>
<li>
<a class="nav-link" (click)="setTitle('About - A2SPA')"
routerLink="/about">About</a>
</li>
<li>
<a class="nav-link" (click)="setTitle('Contact - A2SPA')"
routerLink="/contact">Contact</a>
</li>
</ul>
</div>
</div>
</div>
<div class="container body-content">
<router-outlet></router-outlet>
@{
string razorServerSideData = "ASP.Net Core";
}
<hr />
<footer>
<p>© 2017 - A2SPA = (@razorServerSideData +
{{angularClientSideData}})<sup>2</sup></p>
</footer>
</div>
Note the new Angular directive, <router-outlet>
will be loading our Angular content.
Next update /views/home/index.cshtml to this:
<my-app>Loading AppComponent content here ...</my-app>
And though we will still use /views/shared/_layout.cshtml, it needs to be modified as menus are no longer handled there, but in our new AppComponent.cshtml, so replace the current contents of _layout.cshtml with this:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - A2SPA</title>
<base href="~/">
<environment names="Development">
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
<link rel="stylesheet" href="~/css/site.css" />
</environment>
<environment names="Staging,Production">
<link rel="stylesheet"
href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/css/bootstrap.min.css"
asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
asp-fallback-test-class="sr-only" asp-fallback-test-property="position"
asp-fallback-test-value="absolute" />
<link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
</environment>
</head>
<body>
@RenderBody()
<environment names="Development">
<script src="~/lib/jquery/dist/jquery.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<!--
<script src="/node_modules/core-js/client/shim.min.js"></script>
<script src="/node_modules/zone.js/dist/zone.js"></script>
<script src="/node_modules/systemjs/dist/system.src.js"></script>
<script src="~/systemjs.config.js"></script>
<script>
System.import('app').catch(function (err) { console.error(err); });
</script>
</environment>
<environment names="Staging,Production">
<script src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-2.2.0.min.js"
asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
asp-fallback-test="window.jQuery">
</script>
<script src="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.6/bootstrap.min.js"
asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.min.js"
asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal">
</script>
<script src="~/js/site.min.js" asp-append-version="true"></script>
<!--
<script src="/node_modules/core-js/client/shim.min.js"></script>
<script src="/node_modules/zone.js/dist/zone.js"></script>
<script src="/node_modules/systemjs/dist/system.src.js"></script>
<script src="~/systemjs.config.js"></script>
<script>
System.import('app').catch(function (err) { console.error(err); });
</script>
</environment>
@RenderSection("scripts", required: false)
</body>
</html>
To test the updates, we'll need to update our app.component.ts to point to the new template driven from the partial controller, to this:
import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
@Component({
selector: 'my-app',
templateUrl: '/partial/appComponent'
})
export class AppComponent {
public constructor(private titleService: Title) { }
angularClientSideData = 'Angular';
public setTitle(newTitle: string) {
this.titleService.setTitle(newTitle);
}
}
The title service is a special service used to change the HTML page title added in Angular 2, as controllers can no longer be located outside the HTML <body>
.
To ensure we get our about, contact and index pages working again, we need to add routing, as well as components for each.
First in the /wwwroot/app folder, create the following three components:
Create about.component.ts containing the following:
import { Component } from '@angular/core';
@Component({
selector: 'my-about',
templateUrl: '/partial/aboutComponent'
})
export class AboutComponent {
}
Next, create contact.component.ts containing the following:
import { Component } from '@angular/core';
@Component({
selector: 'my-contact',
templateUrl: '/partial/contactComponent'
})
export class ContactComponent {
}
Lastly, create index.component.ts containing the following:
import { Component } from '@angular/core';
@Component({
selector: 'my-index',
templateUrl: '/partial/indexComponent'
})
export class IndexComponent {
}
Next we add our routing logic, create the file app.routing.ts and add the following code inside it:
import { Routes, RouterModule } from '@angular/router';
import { AboutComponent } from './about.component';
import { IndexComponent } from './index.component';
import { ContactComponent } from './contact.component';
const appRoutes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: IndexComponent, data: { title: 'Home' } },
{ path: 'about', component: AboutComponent, data: { title: 'About' } },
{ path: 'contact', component: ContactComponent, data: { title: 'Contact' } }
];
export const routing = RouterModule.forRoot(appRoutes);
export const routedComponents = [AboutComponent, IndexComponent, ContactComponent];
To include these new files, we'll update app.module.ts from this:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
@NgModule({
imports: [ BrowserModule ],
declarations: [ AppComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
To this:
import { NgModule, enableProdMode } from '@angular/core';
import { BrowserModule, Title } from '@angular/platform-browser';
import { routing, routedComponents } from './app.routing';
import { APP_BASE_HREF, Location } from '@angular/common';
import { AppComponent } from './app.component';
@NgModule({
imports: [BrowserModule, routing],
declarations: [AppComponent, routedComponents],
providers: [Title, { provide: APP_BASE_HREF, useValue: '/' }],
bootstrap: [AppComponent]
})
export class AppModule { }
We'll update our main.ts file from this:
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';
platformBrowserDynamic().bootstrapModule(AppModule);
To allow some further logging, we use this instead:
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';
platformBrowserDynamic().bootstrapModule(AppModule)
.then((success: any) => console.log('App bootstrapped'))
.catch((err: any) => console.error(err));
And now, we've completed our first ASP.NET Core + Angular 2 SPA site, delete the initial Angular 2 index.html file from /wwwroot, since we're using the ASP.NET Core /home/index view, it's no longer needed.
Finally we're ready to re-test our ASP.NET Core + Angular 2 SPA site, rebuild your project, hit Ctrl-F5 to launch your browser.
You should be able to navigate to the root directory, or /home or /home/index interchangeably and see the same thing, a page that resembles the original standard ASP.NET MVC /home/index page:
Click on the "logo" A2SPA or the "Home" link should again show the same page, as above.
Click on "About" in the menu and you should see:
And click on "Contact" in the menu, we see:
So what has changed? A close look at the copyright message at the bottom should reveal something a little different from the original:
Referring back to our source, for /views/partial/AppComponent.cshtml, you see:
<div class="container body-content">
<router-outlet></router-outlet>
@{
string razorServerSideData = "ASP.Net Core";
}
<hr />
<footer>
<p>© 2017 - A2SPA = (@razorServerSideData +
{{angularClientSideData}})<sup>2</sup></p>
</footer>
</div>
Where there are examples of Razor markup that get executed on the server, "flat" HTML that is delivered as-is, as well as an Angular 2 directive "<router-outlet>
" into which the Angular templates are pushed client-side, inside the browser, and finally an example of Angular 2 data binding with {{angularClientSideData}
} that is executed client side as well.
Next in Part 2 of this series, we'll look at extending these concepts to include custom tag helpers, C# code executed server-side against a data model and enable us to create form elements, or whole forms, comprising HTML, angular validation and bootstrap styling all at once, and created all in one place -where your code is "DRY" - adhering to the Don't Repeat Yourself ideal, that is without cut/paste as so often comes with SPAs of any sort.
Part 3 extends the data services, adds EF Core / entity framework and a simple SQL backend, including some sample data and then provides tag helper classes for data entry.
Part 4 will add token validation and start using Swagger tools create Angular data type definitions and services dynamically. Later articles will cover a few practical aspects of using the framework in a commerical application and how to publish to IIS.
Getting the Source
If you'd like to save time, the latest source of this is on GitHub here.
Remember this was created using Visual Studio 2015 and the latest tooling, available in January 2017.
Note: If you use Visual 2017 RC on the VS 2015 solution, it will perform a one way conversion. If there is enough interest, I'll create an article covering the VS 2017 steps, though they are very similar to those shown here.
History
- 22nd January, 2017: Initial version
Coming soon custom tag helpers, data services and token based security.