Single Page Application, a fundamental requirement for SPA is to develop the application with XMLHttpRequest (XHR) or AJAX. That is only main requirement, other things come as add-on stuff.
Earlier days one-way, two-way data binding with templates were very minimum things to expect from frameworks. Now they talk about component structure, Virtual Dom, IOC, state management, performance etc.
To compile things, webpack uses different loaders available through NPM. We can filter out files with test attribute and put loader library name in it. You can see, for various files, there are different loaders which can also be chained up to have input from an output of another dependent loader.
The other take away from config.
This would generate two files, one Vendor which will include files from different libraries as one, the other App file would be a combined file from different TS code files.
The provided configuration value are the path of code files. The reason vendor file is directly mapped is we want to expose that file directly whereas ./clientapp would be using Index.ts. The idea with Index.ts is to keep exporting files which need to be public. So, in that sense, each folder under Clientapp can have Index.ts to keep exporting files and ultimately ./clientapp/index.ts file would be read to generate, as a final single file. You can see from image, almost all folders have Index.ts under AppArea folder. In the component folder, IndexInternal.ts are present the reason is we do not want those to be public, just need to reference within the Controllers/public facing JS/TS. It has the same structure as Index.ts but referencing those manually by specifying the file name IndexInternal.ts.
A sample code available from index.ts. These exports would be similar on IndexInternal.ts as well.
webpack does take input from tsconfig as well for the compilation of TS files.
Offcourse the tsconfig and webpack.config can be modified as per need basis and all those configurations may not be relevant to you or need any additional stuff. Like there could be a different configuration for Dev, Staging, Production and so on.
Also to start application need, to include this:
In a similar way, we would need to create some rules that need to be followed while creating TS files. Please relate to shared image as well.
Advancements of SPA?
In today's world, we do not want to be restricted with basic features or functionalities. We want everything like any new Phone comes in market with some new shiny feature. There are a lot of functionalities available with SPA libraries and could be extended with more extension to have richer and easier implementation. To be specific we shall refer it as frameworks, not libraries.Earlier days one-way, two-way data binding with templates were very minimum things to expect from frameworks. Now they talk about component structure, Virtual Dom, IOC, state management, performance etc.
Why building own framework/library?
What I believe and experienced is each SPA JS Frameworks has its own set of rules and structure that has to be followed. If we want to have our own dynamic things or we want to change some structure than we have to dig down into that framework, understand and change accordingly. I do not mean it is undoable or not flexible to change but we got to spend more time with it and when we have to switch to another SPA framework, so it's of no use. I understand this is a general thing in the Developer world but these frameworks have a completely different approach with each other. Thankfully with advancements of ECMAScript things are lining up.
If we have to follow structure than why not our own with the features we need and structure that we like.
I guess that is it, if you do not want to look into another implementation, you should stop reading. The implementation is not any way similar or having greater features than existing SPAs frameworks. Maybe you would find it limited, restricted, incomplete etc. But I can tell you that it can work on any medium to large enterprises application with Agile development, manageable client-side codes.
Why?
My vision/requirements for creating this are as follows:
- Code Manageability and easy refactoring.
- MVC like structure, similar to server-side codes.
- Design as components, each component would be applicable to single view/feature, small area.
- Easy to change. (Maybe I am repeating again)
- Easier to develop, with minimum or small learning curve.
- My favorite OOPS, with proper way/channel to communicate.
- Using jQuery. I know for many of us, the story ends here. SPA frameworks provide abstractions and recommend not to use it for performance reason but a lot of times even after provided abstraction we end up using it. For me, it just easier way and the most strong reason is there are a lot of plugins available which are easy-peasy with different version and personalized flavors based on requirement. [Probably this is going to change in future, with all those advancements and avoiding of DOM]
There could be few/much more but those are primary items.
What are tech/tools/libraries/technique chosen? Why those are chosen?
- TypeScript: This is just used for writing OOPS codes and other features from TS in an easier way. Direct JS would take a lot of codes.
- jQuery: Interacting with DOM and with various plugins.
- jQuery Validation and jQuery Unobtrusive Validation: The validation implementation for the forms. As of now, we would be looking into implementation to add validation unobtrusively by directly putting it on HTML data attributes.
- Handlebars.js: The template engine to merge our Models with HTML. The HTML/View rendering would be taken care by this library.
- InversifyJS: The name itself says something related to IOC. Through this, we would be using DI, as need basis. In this architecture, we are going to avoid creation of instance manually. This would allow us to fuse classes with other dependent class in much easier manner and loosely coupled approach.
- DirectorJS: This is our routing library, which would allow us to have client-side routing. There are various client-side routing available, but I liked the approach with the forward routing which would automatically resolve any route if a user requests any specific deep URL path. Also, it could be separated into various places of codes for better code manageability.
- Any client side UI framework: Bootstap, MaterialUI etc.
- SCSS[Optional]: This just a better way to manage our CSS.
- WebPack: This is a really important piece of tool to compile all of our client-side codes. The responsibilities of it are TS Compilation, Handlebar compilation, used library compilation, HMR[Optional], SCSS compilation.
- Guidelines: The other very point to is to follow guidelines, like naming files, folders, not using DOM elements in codes, DI implementation and it's usage, routing approach. I would discuss these as we progress with the article.
Before jumping into codes let's talk what are we trying to achieve here and what all are processes to achieve same
https://github.com/viku85/CustomSpaApp
This is not going to have full details for each and everything, it may need some prior knowledge but I would try to give details to easily start with.
The first thing webpack configuration which will build the entire application.
- Single Page application.
- The whole flow of the application needs to be derived through routing. Director would be used for same.
- Component or self-contained package structure. When we say Login, Password recovery, these would be self-packaged items. If for some reason those needs to communicate with each other than it has to be through callback approach, similar to events, constructor injection.
- Any type of data posting has to be wrapped with form and that would be taken care by helper functions to automatically post and receive data and populate warnings in case of any error.
- There could not be any direct dependency between two components, meaning we should not create an object manually (except few cases), it has to be received through wrapper function which would be using Inversify DI.
Implementation: starting with configurations and code structures
Enough of talking, let's get our hands dirty with some coding and implementation now. This is going to have a lot of information. I would try to explain all major parts of it. I have already put codes in Github, can have a look and then go through details here.https://github.com/viku85/CustomSpaApp
This is not going to have full details for each and everything, it may need some prior knowledge but I would try to give details to easily start with.
The first thing webpack configuration which will build the entire application.
webpack.config.js
var path = require("path");
var webpack = require("webpack");
var ExtractTextPlugin = require("extract-text-webpack-plugin");
module.exports = {
//watch: true,
cache: true,
entry: {
Vendor: "./clientapp/infrastructure/vendor.ts",
App: './clientapp'
},
output: {
path: path.join(__dirname, "/wwwroot/app"),
filename: "SpaApp.[name].js",
chunkFilename: 'chunks/[name].chunk.js',
libraryTarget: 'umd',
library: ['SpaApp', "[name]"],
publicPath: '/app/'
},
resolve: {
extensions: ['.ts', '.js', '.webpack.js', '.web.js'],
alias: {
'handlebars': 'handlebars/runtime.js'
}
},
devtool: 'source-map',
module: {
loaders: [{
test: /\.ts$/,
loader: ['ts-loader'],
exclude: /node_modules/
},
{
test: /\.scss$/,
use: ['css-hot-loader'].concat(ExtractTextPlugin.extract({
fallback: "style-loader",
use: [{
loader: 'css-loader'
}, {
loader: 'sass-loader',
options: {
includePaths: [
path.resolve(__dirname, 'node_modules'),
path.resolve(__dirname, 'node_modules/@material/*')]
}
}
]
}))
},
{
test: /\.css$/,
use: ['css-hot-loader'].concat(ExtractTextPlugin.extract({
fallback: "style-loader",
use: 'css-loader'
}))
},
{
test: /\.(png|jpg|jpeg|gif)$/,
loader: 'file-loader',
exclude: /node_modules/
},
{
test: /\.(eot|svg|ttf|woff|woff2)$/,
loader: 'file-loader?name=fonts/[name].[ext]'
},
{
test: require.resolve("jquery"),
loader: "expose-loader?$!expose-loader?jQuery"
},
{
test: require.resolve("material-components-web"),
loader: "expose-loader?mdc"
},
{
test: /\.handlebars$/,
loader: "handlebars-loader"
}
]
},
plugins: [
new ExtractTextPlugin('style.css'),
new webpack.LoaderOptionsPlugin({
debug: true,
minimize: true
})
]
};
To compile things, webpack uses different loaders available through NPM. We can filter out files with test attribute and put loader library name in it. You can see, for various files, there are different loaders which can also be chained up to have input from an output of another dependent loader.
The other take away from config.
entry: {
Vendor: "./clientapp/infrastructure/vendor.ts",
App: './clientapp'
},
This would generate two files, one Vendor which will include files from different libraries as one, the other App file would be a combined file from different TS code files.
The provided configuration value are the path of code files. The reason vendor file is directly mapped is we want to expose that file directly whereas ./clientapp would be using Index.ts. The idea with Index.ts is to keep exporting files which need to be public. So, in that sense, each folder under Clientapp can have Index.ts to keep exporting files and ultimately ./clientapp/index.ts file would be read to generate, as a final single file. You can see from image, almost all folders have Index.ts under AppArea folder. In the component folder, IndexInternal.ts are present the reason is we do not want those to be public, just need to reference within the Controllers/public facing JS/TS. It has the same structure as Index.ts but referencing those manually by specifying the file name IndexInternal.ts.
A sample code available from index.ts. These exports would be similar on IndexInternal.ts as well.
export * from './AppArea';
export * from './Infrastructure';
webpack does take input from tsconfig as well for the compilation of TS files.
tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": [ "es6", "dom" ],
"module": "commonjs",
"sourceMap": true,
"sourceRoot": "App",
"outDir": "wwwroot/app",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"paths": {
"*": [
"./ClientApp/CustomTyping/*"
]
}
},
"compileOnSave": false,
"exclude": [
"node_modules",
"wwwroot"
]
}
vendor.ts
As discussed, this file contains all the references of third party libraries which would be resolved through webpack by using NPM packages.
CSS import is done at this place only.
import 'director/build/director.js';
//import 'material-components-web';
import 'jquery';
import 'jquery-validation';
import 'jquery-validation-unobtrusive';
import 'bootstrap-sass/assets/javascripts/bootstrap';
import 'lodash';
import 'reflect-metadata';
import 'inversify';
import 'handlebars';
// CSS
import '../Css/Site.scss';
Using reference of above files in our main HTML
The compiled codes would be generated in wwwroot/app folder. You can see just compiled code JS and CSS are used in _Layout.cshtml. All generated, files would be prefixed with SpaApp as per mentioned setting in webpack.config. There is some incompatibility with DirectorJS, that is why it is directly mentioned.
_Layout.cshtml: This is based on dotnet MVC but can be simple HTML as well.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - SpaApp</title>
<link rel="stylesheet" href="~/app/style.css" />
</head>
<body>
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<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 href="#" class="navbar-brand">SpaApp</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="#">Home</a></li>
<li><a href="#/about">About</a></li>
<li><a href="#/about/contact">Contact</a></li>
</ul>
</div>
</div>
</nav>
<div class="container body-content">
@RenderBody()
<div class="app-main">
</div>
<hr />
<footer>
<p>© 2017 - SpaApp</p>
</footer>
</div>
<script src="https://rawgit.com/flatiron/director/master/build/director.min.js"></script>
<script src="~/app/SpaApp.vendor.js"></script>
<script src="~/app/SpaApp.app.js"></script>
@RenderSection("Scripts", required: false)
</body>
</html>
Also to start application need, to include this:
<script type="text/javascript">
var app = SpaApp.App;
app.SetConstant(app.HomeViewHolderOption,
new app.HomeViewHolderOption({
ContainerSelector: '.app-main'
}));
new app.MasterRoute();
</script>
The route is initialized through new app.MasterRoute() and through this only whole thing would start to work since application would depend on routing. The SetConstant is for DI initialization, would talk about it later.
These were basic implementation/infrastructure to start coding. Before starting let's go through some guidelines that have to be used throughout the application development.
Guidelines to create files, folders, structure
When we talk about JS, there are no constraints which makes it really powerful and easy to make mistakes, that is where TS comes handy with some certain ground rules that can be applied. [Yes, there are Static Code Analysis tools available to force it].In a similar way, we would need to create some rules that need to be followed while creating TS files. Please relate to shared image as well.
- Class name and file name should be same.
- All codes should be present on ClientApp folders.
- SCSS, CSS files should be created under ClientApp/Css.
- All the building block for the application are present under InfraStructure folder. The Master Route config is available here.
- All the codes related to page, features, HTML need to be placed under AppArea folder with any required nested folders. This is where it is similar to MVC just that it has to be managed by your own with folder structure.
- Keep using Area suffix to divide functionality/features for pages. Each of these areas needs to be treated as self-contained, these should not directly consume others DOM. For example, Home should not directly access DOM for Login. Better to use callbacks for those situations.
- For each main Area, we would need to have specific route configuration for that area along with any nested area.
- Component is a folder where all of our helper codes exist, like for any AJAX calls, Form posting, Validations, Pop-Up, Event helpers and so on. It can be again as nested as you want.
These were basic rules, it could have been discussed at last but good to do at first to have better clarity.
Client-side routing
This is the first thing to start with since this would be an entry point of codes/pages that need to be accessed.
These can be sub-divided into multiple, for an example Account routing which can contain login, password recovery etc. would be in a different section than other modules. So, a master route would be using direct child routes, and those child routes can have its own sub child.
Master route
import { GetController } from './IocConfig';
import { AboutAreaRoute } from './../AppArea/AboutArea';
import { HomeController, AboutController } from './../AppArea';
declare var Router: any;
declare var director: any;
export class MasterRoute {
constructor() {
this.Init();
}
Init(): void {
var router: any = null;
router = Router(this.GetRoutes()).configure({
on: this.GetListener(),
recurse: 'forward',
async: true
}).init('/');
}
GetRoutes() {
var aboutAreaRoute = new AboutAreaRoute().InitRoute();
var routes = {
'/': {
on: (next) => {
document.title = "Home | SpaApp";
GetController<HomeController>(HomeController).Init(next);
}
}
};
$.extend(routes, aboutAreaRoute);
return routes;
}
public GetListener() {
console.log("Listener at: " + window.location);
}
}
Router is provided through DirectorJS which is initialized by putting GetRoutes() function. The implementation is simple with JSON composition. We put master route by using '/' and having code on on callback where we execute codes. But in callback, we are just having GetController<HomeController>(HomeController).Init(next), this is how we would keep getting instance but take away from this is the next it is kind of wrapper for callback. For an example, if someone wants to stop propagating URL from / to any nested route they can block that up through this.
The var aboutAreaRoute = new AboutAreaRoute().InitRoute() is a container of child route which is added into main route through $.extend(routes, aboutAreaRoute).
Nothing fancy about child route here is the code for above:
There is a huge issue with route and rendering of HTML. When someone comes to nested route by putting URL in a browser than whole route path would be executed from root (/) to nested route. To overcome this issue we would be having a check on each parent controller if any of it's associated child component is rendered. The idea is to restrict re-rendering of parent DOM element if child DOM exists and to achieve it we would be setting HTML data attribute on parent element whenever child element is initialized but in a cleaner way. We would see that once we are into controllers.
The var aboutAreaRoute = new AboutAreaRoute().InitRoute() is a container of child route which is added into main route through $.extend(routes, aboutAreaRoute).
Nothing fancy about child route here is the code for above:
import { GetController, GetObject } from './../../Infrastructure/IocConfig';
import { AboutController } from './AboutController';
import { AboutViewOption } from './AboutViewOption';
import { ContactController } from './ContactArea/ContactController';
class AboutAreaRoute {
InitRoute() {
var route = {
'/about': {
'/contact': {
on: (next) => {
document.title = "About | Contact | SpaApp";
GetController<ContactController>(ContactController).Init(next);
},
},
on: (next) => {
document.title = "About | SpaApp";
GetObject<AboutController>(AboutController)
.Init(GetObject<AboutViewOption>(AboutViewOption), 'about', next);
}
}
};
return route;
}
}
export { AboutAreaRoute }
There is a huge issue with route and rendering of HTML. When someone comes to nested route by putting URL in a browser than whole route path would be executed from root (/) to nested route. To overcome this issue we would be having a check on each parent controller if any of it's associated child component is rendered. The idea is to restrict re-rendering of parent DOM element if child DOM exists and to achieve it we would be setting HTML data attribute on parent element whenever child element is initialized but in a cleaner way. We would see that once we are into controllers.
Dependency Injection (DI)
In this, we are not going to implement DI based on interface and class but at least constructor injection. This would free us from creating an object if it has multiple dependencies.
We would be creating a wrapper class to get or set injection as needed and setting up controller injection within the same file.
Helper items are singleton whereas controllers are scoped. Since we do not need to have multiple instances created for helpers wheres controllers has to re-initialize.
GetObject and GetController are used to get items which are already present in DI. The one item SetConstant is used to set a new item as a singleton. We would see the usage of same later. The GetController we have already seen how to use it. There are few Generic constraints set on them.
To enable classes to use DI, we need to set attribute @injectable() on those classes.
HomeView is holding up handlebars template which is rendered on InitPage function.
The condition that we are checking by URL and ContainerSelector to avoid re-render if any nested URL is requested. The child-init data attribute would be set as true by child controller.
The other thing to mark is HomeViewHolderOption, this is going to be initialized by a parent, in this situation, we have used with SetConstant while initializing master route.
No, DOM element or selectors should be used on TS code level and ideally, above items should be initialized on handlebars where actual DOM elements exist.
The code structure for above:
At this place, we are creating an interface and inheriting same to have easier access on TS class as option constructor variable, so that we get intellisense and use the same type to initialize the class.
Like I said earlier there is no need of model always, same in this case.
Now, we would see one other example of child Controller with form involved in it.
Check at this place where we not checking for URL but just for content initialization also for parent item with are setting true for child-init data attribute.
You can see how unobtrusive validations are constructed on all input elements based jQuery unobtrusive validation. The next thing to look out for initialization of ContactViewOption and initializing ContactForm.
This is a sample there could be many things done but having a simplified version. Also, you can see how simple it is to use form callbacks. If it is just about form than globally form element can be registered to handle post and getting the result.
Tons of codes with various callbacks but for easier understanding just have a look on the associated interface that can give you a fair idea about it. It is going to fill up more space to explain all things, I will leave it to you to explore on it.
We would be creating a wrapper class to get or set injection as needed and setting up controller injection within the same file.
import { Container, interfaces } from 'inversify';
import { HttpRequestResponse, JqueryEventHelper, Form } from './../Component/IndexInternal';
import { HomeController } from './../AppArea/HomeArea/HomeController';
import { AboutController } from './../AppArea/AboutArea/AboutController';
import { ContactController } from './../AppArea/AboutArea/ContactArea/ContactController';
import { ContactForm } from './../AppArea/AboutArea/ContactArea/ContactForm';
import { BaseController } from './../Base/BaseController';
let container = new Container();
// Helper
container.bind<HttpRequestResponse>(HttpRequestResponse).toSelf().inSingletonScope();
container.bind<JqueryEventHelper>(JqueryEventHelper).toSelf().inSingletonScope();
container.bind<Notification>(Notification).toSelf().inSingletonScope();
container.bind<Form>(Form).toSelf().inSingletonScope();
// Controllers
container.bind<HomeController>(HomeController).toSelf();
container.bind<AboutController>(AboutController).toSelf();
container.bind<ContactController>(ContactController).toSelf();
container.bind<ContactForm>(ContactForm).toSelf();
function GetObject<T>(service: any): T {
return IsAlreadyInitilized(service) ? <T>container.get(service) : null;
}
function IsAlreadyInitilized(service): boolean {
return container.isBound(service);
}
function SetConstant(service: any, instanc: any) {
if (!container.isBound(service)) {
container.bind(service)
.toConstantValue(instanc);
}
else {
console.log(`${service.name} is already registered.`);
}
}
function GetController<T extends BaseController>(
controller: interfaces.ServiceIdentifier<T>): T {
return container.get<T>(controller);
}
export { GetObject, SetConstant, GetController, IsAlreadyInitilized };
Helper items are singleton whereas controllers are scoped. Since we do not need to have multiple instances created for helpers wheres controllers has to re-initialize.
GetObject and GetController are used to get items which are already present in DI. The one item SetConstant is used to set a new item as a singleton. We would see the usage of same later. The GetController we have already seen how to use it. There are few Generic constraints set on them.
To enable classes to use DI, we need to set attribute @injectable() on those classes.
Controllers with handlebars as it's associated Views
Root Controller
import { injectable } from "inversify";
import { HttpRequestResponse } from './../../Component/IndexInternal';
import { BaseController, IGeneralRouteController } from './../../Base/BaseController';
import { HomeViewHolderOption } from './HomeViewHolderOption';
var HomeView = require("./HomeView.handlebars");
@injectable()
class HomeController
extends BaseController
implements IGeneralRouteController {
constructor(public HolderOption: HomeViewHolderOption) {
super();
}
Init(routeNextWrapper?: (isInitialized?: boolean) => void) {
if (window.location.hash.slice(2) == '' ||
!$(this.HolderOption.ContainerSelector).data('child-init')) {
this.InitPage();
}
routeNextWrapper();
}
InitPage() {
$(this.HolderOption.ContainerSelector).html(HomeView);
}
}
export { HomeController }
HomeView is holding up handlebars template which is rendered on InitPage function.
The condition that we are checking by URL and ContainerSelector to avoid re-render if any nested URL is requested. The child-init data attribute would be set as true by child controller.
The other thing to mark is HomeViewHolderOption, this is going to be initialized by a parent, in this situation, we have used with SetConstant while initializing master route.
var app = SpaApp.App;
app.SetConstant(app.HomeViewHolderOption,
new app.HomeViewHolderOption({
ContainerSelector: '.app-main'
}));
No, DOM element or selectors should be used on TS code level and ideally, above items should be initialized on handlebars where actual DOM elements exist.
The code structure for above:
import { injectable } from 'inversify';
@injectable()
class HomeViewHolderOption
implements IHomeViewHolderOption {
constructor(option: IHomeViewHolderOption) {
this.ContainerSelector = option.ContainerSelector;
}
ContainerSelector: string;
}
interface IHomeViewHolderOption {
ContainerSelector: string;
}
export { HomeViewHolderOption, IHomeViewHolderOption }
At this place, we are creating an interface and inheriting same to have easier access on TS class as option constructor variable, so that we get intellisense and use the same type to initialize the class.
View for above controller
<div id="myCarousel" class="carousel slide" data-ride="carousel" data-interval="6000">
<ol class="carousel-indicators">
<li data-target="#myCarousel" data-slide-to="0" class="active"></li>
<li data-target="#myCarousel" data-slide-to="1"></li>
<li data-target="#myCarousel" data-slide-to="2"></li>
<li data-target="#myCarousel" data-slide-to="3"></li>
</ol>
<div class="carousel-inner" role="listbox">
.... // HTML only, check Github for full
</div>
</div>
Now, we would see one other example of child Controller with form involved in it.
ContactController.ts
import { injectable } from "inversify";
import { GetObject } from './../../../Infrastructure/IocConfig';
import { HttpRequestResponse } from './../../../Component/IndexInternal';
import { BaseController, IGeneralRouteController } from './../../../Base/BaseController';
import { AboutViewOption } from './../AboutViewOption';
import { ContactViewOption, IContactViewOption } from './ContactViewOption';
import { Form, JqueryEventHelper } from './../../../Component/IndexInternal';
var ContactView = require("./ContactView.handlebars");
@injectable()
class ContactController
extends BaseController
implements IGeneralRouteController {
constructor(public AboutViewOption: AboutViewOption,
readonly FormHelper: Form,
readonly EventHelper: JqueryEventHelper) {
super();
}
Init(routeNextWrapper?: (isInitialized?: boolean) => void) {
if (!$.trim($(this.AboutViewOption.ContactContainerSelector).html()).length) {
this.InitPage();
}
routeNextWrapper();
}
InitPage() {
$(this.AboutViewOption.ContactContainerSelector).html(ContactView({
Title: 'Contact',
Message: 'Your contact page.'
})).data('child-init', true);
}
}
export { ContactController }
Check at this place where we not checking for URL but just for content initialization also for parent item with are setting true for child-init data attribute.
View of above (ContactView.handlebars)
<hr />
<h2>{{Title}}</h2>
<h3>{{Message}}</h3>
<div class="container">
<div class="row">
<div class="col-md-8">
<div class="well well-sm">
<form id="ContactForm" method="post" action="/home/contact">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="name">
Name
</label>
<input type="text" class="form-control" id="Name" name="Name" placeholder="Enter name"
data-val="true"
data-val-length="The Name must be of 2 to 150 characters."
data-val-length-max="150" data-val-length-min="2"
data-val-required="Name is required."
value="VK" />
<div class="field-validation-error"
data-valmsg-for="Name" data-valmsg-replace="true" id="NameError">
</div>
</div>
<div class="form-group">
<label for="email">
Email Address
</label>
<div class="form-group">
<div class="input-group">
<span class="input-group-addon">
<span class="glyphicon glyphicon-envelope"></span>
</span>
<input type="text" class="form-control" id="Email" placeholder="Enter email"
name="Email"
data-val="true"
data-val-required="Email is required."
data-val-regex="Invaild email id."
data-val-regex-pattern="^\w+@[a-zA-Z_]+?\.[a-zA-Z]{2,3}$"
value="viku85@gmail.com" />
</div>
<div class="field-validation-error"
data-valmsg-for="Email" data-valmsg-replace="true" id="EmailError">
</div>
</div>
<div class="form-group">
<label for="subject">
Subject
</label>
<select id="Subject" name="Subject" class="form-control" required="required">
<option value="service">General</option>
<option value="suggestions">Suggestions</option>
<option value="product">Product Support</option>
</select>
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="name">
Message
</label>
<textarea name="Message" id="Message" class="form-control" rows="9" cols="25" required="required"
placeholder="Message"
data-val="true"
data-val-length="The Partner Name must be of 10 to 2000 characters."
data-val-length-max="2000" data-val-length-min="10"
data-val-required="Message is required.">All is well.</textarea>
<div class="field-validation-error"
data-valmsg-for="Message" data-valmsg-replace="true" id="MessageError">
</div>
</div>
<div class="col-md-12">
<button type="submit" class="btn btn-primary pull-right" id="ContactUsSubmit">
Send Message
</button>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="col-md-4">
<legend><span class="glyphicon glyphicon-globe"></span> Our office</legend>
<address>
One Microsoft Way<br />
Redmond, WA 98052-6399<br />
<abbr title="Phone">P:</abbr>
425.555.0100
</address>
<address>
<strong>Support:</strong> <a href="mailto:Support@example.com">Support@example.com</a><br />
<strong>Marketing:</strong> <a href="mailto:Marketing@example.com">Marketing@example.com</a>
</address>
</div>
</div>
</div>
<script type="text/javascript">
var app = SpaApp.App;
app.SetConstant(app.ContactViewOption,
new app.ContactViewOption({
FormSelector: '#ContactForm',
SubmitButtonSelector: '#ContactUsSubmit'
}));
app.GetObject(app.ContactForm);
</script>
You can see how unobtrusive validations are constructed on all input elements based jQuery unobtrusive validation. The next thing to look out for initialization of ContactViewOption and initializing ContactForm.
ContactForm.ts
import { injectable } from "inversify";
import { GetObject } from './../../../Infrastructure/IocConfig';
import { HttpRequestResponse } from './../../../Component/IndexInternal';
import { BaseController, IGeneralRouteController } from './../../../Base/BaseController';
import { AboutViewOption } from './../AboutViewOption';
import { ContactViewOption, IContactViewOption } from './ContactViewOption';
import { Form, JqueryEventHelper } from './../../../Component/IndexInternal';
@injectable()
class ContactForm {
constructor(
readonly ContactViewOption: ContactViewOption,
readonly FormHelper: Form,
readonly EventHelper: JqueryEventHelper) {
this.Init();
}
Init() {
this.EventHelper.RegisterClickEvent(
this.ContactViewOption.SubmitButtonSelector,
(evt, selector) => {
this.FormHelper.SubmitForm({
Source: {
ButtonEvent: evt
},
OnPostSuccessResult: (data) => {
console.log('Submitted successfully.');
}
});
});
}
}
export { ContactForm }
This is a sample there could be many things done but having a simplified version. Also, you can see how simple it is to use form callbacks. If it is just about form than globally form element can be registered to handle post and getting the result.
Example of one component, form.ts
import { injectable } from "inversify";
@injectable()
class Form {
constructor() {
}
SubmitForm(option: AjaxFormPostContent) {
var form: JQuery;
var buttonSource: JQuery;
var curEvent: JQuery.Event<HTMLElement, null>;
if (option.Source.ButtonEvent) {
buttonSource = $(option.Source.ButtonEvent.currentTarget);
form = buttonSource.closest("form");
curEvent = option.Source.ButtonEvent;
}
else if (option.Source.FormSubmitEvent) {
form = $(option.Source.FormSubmitEvent.currentTarget);
curEvent = option.Source.FormSubmitEvent;
}
curEvent.preventDefault();
var formId = $(form).attr('id');
if (!formId) {
console.error('form id not specified');
return;
}
var triggeredForm = "#" + formId;
if (!$(triggeredForm).attr('action').length) {
console.error('URL missing for form submit.');
return;
}
if (form.hasClass("loading")) {
curEvent.preventDefault();
return;
}
form.addClass("loading");
$.each($(form).find("button[type='submit']"), (index, btn) => {
$(btn).addClass("loading");
});
$.validator.unobtrusive.parse(`#${formId}`);
var serializedData = {};
$(triggeredForm).serializeArray()
.map((key) => { serializedData[key.name] = key.value; });
if (option.SerializeData != undefined) {
option.SerializeData(serializedData);
}
var formProgress = (state: boolean) => {
// TODO: Add progess status for form.
};
if ($(triggeredForm).valid()) {
formProgress(true);
$.ajax({
url: $(triggeredForm).attr('action'),
data: JSON.stringify(serializedData),
contentType: 'application/json',
method: 'post',
success: (data) => {
if (data != undefined && data != '') {
if (option.OnPostSuccess != undefined) {
option.OnPostSuccess(data);
}
if (option.OnPostSuccessResult) {
option.OnPostSuccessResult(data);
// TODO: Success notification
}
return;
}
},
error: (data, b, c) => {
if (data != undefined &&
data.responseJSON != undefined &&
data.status == 400) {
if (option.OnValidationFailure) {
option.OnValidationFailure(data.responseJSON);
}
if (option.OnValidationFailureMessageHandling == undefined) {
var message = '';
var propStrings = Object.keys(data.responseJSON);
$.each(propStrings, (errIndex, propString) => {
var propErrors = data.responseJSON[propString];
$.each(propErrors, (errMsgIndex, propError) => {
message += propError;
});
message += '\n';
$(`#${propString}Error`).html(message)
.removeClass('field-validation-valid').addClass('field-validation-error');
message = '';
});
}
else {
option.OnValidationFailureMessageHandling(data.responseJSON);
}
return;
}
if (option.OnFailure != undefined) {
option.OnFailure();
}
else {
// TODO: Failure notification
}
},
complete: () => {
formProgress(false);
}
});
}
form.removeClass("loading");
$.each($(form).find("button[type='submit']"), (index, btn) => {
$(btn).removeClass("loading");
});
// TODO: Reset loading state of buttons that are blocked in initial function start
}
}
interface AjaxFormPostContent {
Source: {
ButtonEvent?: JQuery.Event<HTMLElement, null>,
FormSubmitEvent?: JQuery.Event<HTMLElement, null>,
},
SerializeData?: (data) => void;
OnPostSuccess?: (data: string) => void;
OnFailure?: () => void;
OnPostSuccessResult?: (data: any) => void;
OnValidationFailure?: (data: JSON) => void;
OnValidationFailureMessageHandling?: (data: JSON) => void;
}
export { Form, AjaxFormPostContent }
Tons of codes with various callbacks but for easier understanding just have a look on the associated interface that can give you a fair idea about it. It is going to fill up more space to explain all things, I will leave it to you to explore on it.
Enhancements
I understand there could be numerous enhancements, missing items, flaws but this shall give you better start if you are looking for building something from scratch for SPA app and want to control in your own way.
I did add few more components in it, like pop-up, notification, feel free keep adding and using it. More of all Area, Controller, View, and routing are okay. These give details about all the feature that we discussed in beginning.
For me, I am working on these enhancements in next iteration
- Director with webpack integration
- Routing to pass values from route to controller, DirectorJS has few issue.
- Validation approach by using server-side Data Annotation or Fluent validation so that we write validation logic only at one place.
Please feel free to contribute on Github or ask any query.
Please feel free to contribute on Github or ask any query.
Comments
Post a Comment