Let's start with a thanks in advance :)
OK, So I'm trying to load/map hierarchical TypeScript/KnockoutJS typed classes from matching JSON data using the knockout.mapping plugin, the hierarchy can be to the Nth degree.
I know I can do the following to map/load the top level class from the JSON data.
var qry = ko.mapping.fromJS(jsData, {}, new Query());
However I can't figure out is how to map/load complex, Nth degree, hierarchical JSON data to a set of TypeScript/KnockoutJS classes and build the parent/child relationship.
I've read countless articals, but they all fall short when it comes to hierarchical relationships beyond simple parent/child examples, and I can find none using the knockout.mapping plugin.
Here are my cut down definitions of TypeScript classes I wish to map/load. I'm a c++/c# developer, so JavaScript of this nature is very new to me.
TypeScript Objects
module ViewModel
{
export class QueryModuleViewModel {
public QueryObj: KnockoutObservable<Query>;
constructor() {
this.QueryObj = ko.observable<Query>();
}
public Initialize() {
$.getJSON("/api/query/2", null,
d => {
var qry = ko.mapping.fromJS(d, {}, new Query());
this.QueryObj(qry);
});
}
}
export class Query
{
public ID: KnockoutObservable<number>;
public Name: KnockoutObservable<string>;
public RootTargetID: KnockoutObservable<number>;
public RootTarget: KnockoutObservable<QueryTarget>;
constructor()
{
this.ID = ko.observable<number>(0);
this.Name = ko.observable<string>();
this.RootTargetID = ko.observable<number>();
this.RootTarget = ko.observable<QueryTarget>();
}
}
export class QueryTarget
{
public ID: KnockoutObservable<number>;
public Name: KnockoutObservable<string>;
public ParentID: KnockoutObservable<number>;
public Children: KnockoutObservableArray<QueryTarget>;
public Parent: KnockoutObservable<QueryTarget>;
public Selects: KnockoutObservableArray<QuerySelect>;
public FilterID: KnockoutObservable<number>;
public Filter: KnockoutObservable<FilterClause>;
constructor()
{
this.ID = ko.observable<number>(0);
this.Name = ko.observable<string>();
this.ParentID = ko.observable<number>(0);
this.Children = ko.observableArray<QueryTarget>();
this.Parent = ko.observable<QueryTarget>();
this.Selects = ko.observableArray<QuerySelect>();
this.FilterID = ko.observable<number>(0);
this.Filter = ko.observable<FilterClause>();
}
}
export class QuerySelect
{
public ID: KnockoutObservable<number>;
public Name: KnockoutObservable<string>;
public Aggregation: KnockoutObservable<string>;
public TargetID: KnockoutObservable<number>;
public Target: KnockoutObservable<QueryTarget>;
constructor()
{
this.ID = ko.observable<number>();
this.Name = ko.observable<string>();
this.Aggregation = ko.observable<string>();
this.TargetID = ko.observable<number>();
this.Target = ko.observable<QueryTarget>();
}
}
export class FilterClause
{
public FilterClauseID: KnockoutObservable<number>;
public Type: KnockoutObservable<string>;
public Left: KnockoutObservable<string>;
public Right: KnockoutObservable<string>;
public ParentID: KnockoutObservable<number>;
public Parent: KnockoutObservable<FilterClause>;
public Children: KnockoutObservableArray<FilterClause>;
public QueryTargets: KnockoutObservableArray<QueryTarget>;
constructor()
{
this.FilterClauseID = ko.observable<number>();
this.Type = ko.observable<string>();
this.Left = ko.observable<string>();
this.Right = ko.observable<string>();
this.ParentID = ko.observable<number>();
this.Parent = ko.observable<FilterClause>();
this.Children = ko.observableArray<FilterClause>();
}
}
}
The JSON would look something like this:
{
"ID": 2,
"Name": "Northwind 2",
"RootTargetID": 2,
"RootTarget": {
"ID": 2,
"Name": "Customers",
"ParentID": null,
"FilterID": 2,
"Queries": [],
"Children": [],
"Parent": null,
"Selects": [
{
"ID": 3,
"Name": "CompanyName",
"Aggregation": "None",
"TargetID": 2,
"Target": null
},
{
"ID": 4,
"Name": "ContactName",
"Aggregation": "None",
"TargetID": 2,
"Target": null
}
],
"Filter": {
"FilterClauseID": 2,
"Type": "AND",
"Left": null,
"Right": null,
"ParentID": null,
"QueryTargets": [],
"Parent": null,
"Children": [
{
"FilterClauseID": 3,
"Type": "NE",
"Left": "Country",
"Right": "Germany",
"ParentID": 2,
"QueryTargets": [],
"Parent": null,
"Children": []
},
{
"FilterClauseID": 4,
"Type": "NE",
"Left": "Country",
"Right": "Mexico",
"ParentID": 2,
"QueryTargets": [],
"Parent": null,
"Children": []
}
]
}
}
}
OK, so I'm a little further down the line now, after lots of hair pulling and numerious tests.
Below is a almost working example of what I'm trying to achive, the only problem with this is it doesn't seem to map correctly, even though stepping through the code seems to suggest it is loading correctly. Only when I use it with my bindings it throws a null unreferenced binding on RootTaget.Filter.Type, which should have be populated with a value.
I'm still trying to figure out why, but I will welcome suggestions as to what possible wrong. :)
NOW FIXED AND WORKING
semi-working typescript
///<reference path="Scripts/typings/jquery/jquery.d.ts"/>
///<reference path="Scripts/typings/knockout/knockout.d.ts"/>
///<reference path="Scripts/typings/knockout.mapping/knockout.mapping.d.ts"/>
module ViewModel
{
export class Query {
public ID: KnockoutObservable<number>;
public Name: KnockoutObservable<string>;
public RootTargetID: KnockoutObservable<number>;
public RootTarget: KnockoutObservable<QueryTarget>;
constructor(json: any) {
this.ID = ko.observable<number>(0);
this.Name = ko.observable<string>();
this.RootTargetID = ko.observable<number>();
this.RootTarget = ko.observable<QueryTarget>();
var mapping = {
'RootTarget': {
create: function (args) {
return new QueryTarget(args.data, null);
}
}
};
ko.mapping.fromJS(json, mapping, this);
}
}
export class QueryTarget {
public ID: KnockoutObservable<number>;
public Name: KnockoutObservable<string>;
public ParentID: KnockoutObservable<number>;
public Children: KnockoutObservableArray<QueryTarget>;
public Parent: KnockoutObservable<QueryTarget>;
public Selects: KnockoutObservableArray<QuerySelect>;
public FilterID: KnockoutObservable<number>;
public Filter: KnockoutObservable<FilterClause>;
constructor(json: any, parent: QueryTarget) {
this.ID = ko.observable<number>(0);
this.Name = ko.observable<string>();
this.ParentID = ko.observable<number>(0);
this.Children = ko.observableArray<QueryTarget>();
this.Parent = ko.observable<QueryTarget>(parent);
this.Selects = ko.observableArray<QuerySelect>();
this.FilterID = ko.observable<number>(0);
this.Filter = ko.observable<FilterClause>();
var mapping = {
'Children': {
create: function (args) {
return new QueryTarget(args.data, this);
}
},
'Selects': {
create: function (args) {
return new QuerySelect(args.data, this);
}
},
'Filter': {
create: function (args) {
return new FilterClause(args.data, null);
}
}
};
ko.mapping.fromJS(json, mapping, this);
}
}
export class QuerySelect {
public ID: KnockoutObservable<number>;
public Name: KnockoutObservable<string>;
public Aggregation: KnockoutObservable<string>;
public TargetID: KnockoutObservable<number>;
public Target: KnockoutObservable<QueryTarget>;
constructor(json: any, parent: QueryTarget) {
this.ID = ko.observable<number>();
this.Name = ko.observable<string>();
this.Aggregation = ko.observable<string>();
this.TargetID = ko.observable<number>();
this.Target = ko.observable<QueryTarget>(parent);
ko.mapping.fromJS(json, {}, this);
}
}
export class FilterClause {
public FilterClauseID: KnockoutObservable<number>;
public Type: KnockoutObservable<string>;
public Left: KnockoutObservable<string>;
public Right: KnockoutObservable<string>;
public ParentID: KnockoutObservable<number>;
public Parent: KnockoutObservable<FilterClause>;
public Children: KnockoutObservableArray<FilterClause>;
constructor(json: any, parent: FilterClause) {
this.FilterClauseID = ko.observable<number>();
this.Type = ko.observable<string>();
this.Left = ko.observable<string>();
this.Right = ko.observable<string>();
this.ParentID = ko.observable<number>();
this.Parent = ko.observable<FilterClause>(parent);
this.Children = ko.observableArray<FilterClause>();
var mapping = {
'Children': {
create: function (args) {
return new FilterClause(args.data, this);
}
}
};
ko.mapping.fromJS(json, mapping, this);
}
}
export class QueryModuleViewModel
{
public QueryObj: Query;
constructor() {
var json = {
"ID": 2,
"Name": "Northwind 2",
"RootTargetID": 2,
"RootTarget": {
"ID": 2,
"Name": "Customers",
"ParentID": null,
"FilterID": 2,
"Queries": [],
"Children": [],
"Parent": null,
"Selects": [
{
"ID": 3,
"Name": "CompanyName",
"Aggregation": "None",
"TargetID": 2,
"Target": null
},
{
"ID": 4,
"Name": "ContactName",
"Aggregation": "None",
"TargetID": 2,
"Target": null
}
],
"Filter": {
"FilterClauseID": 2,
"Type": "AND",
"Left": null,
"Right": null,
"ParentID": null,
"QueryTargets": [],
"Parent": null,
"Children": [
{
"FilterClauseID": 3,
"Type": "NE",
"Left": "Country",
"Right": "Germany",
"ParentID": 2,
"QueryTargets": [],
"Parent": null,
"Children": []
},
{
"FilterClauseID": 4,
"Type": "NE",
"Left": "Country",
"Right": "Mexico",
"ParentID": 2,
"QueryTargets": [],
"Parent": null,
"Children": []
}
]
}
}
}
//$.getJSON("/api/query/2", null,
// d => {
// this.QueryObj = new Query(d);
// })
this.QueryObj = new Query(json);
}
}
}
window.onload = () => {
ko.applyBindings(new ViewModel.QueryModuleViewModel());
};
html binding test
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>TypeScript Knockout Mapping Query Test</title>
<link rel="stylesheet" href="app.css" type="text/css" />
<script src="Scripts/jquery-2.0.2.js" type="text/javascript"></script>
<script src="Scripts/knockout-2.2.1.debug.js" type="text/javascript"></script>
<script src="Scripts/knockout.mapping-latest.debug.js" type="text/javascript"></script>
<script src="query.js"></script>
<!--<script src="my_js_query_test_all.js"></script>-->
</head>
<body>
<h1>TypeScript Knockout Mapping Query Test</h1>
<div data-bind="with: QueryObj">
<span data-bind="blah: console.log($context)"></span>
<p>Query Name: <input data-bind="value: Name" /></p>
<hr />
<p>Quick test of RootTarget and Filter data</p>
<p>RootTarget.ID: <input data-bind="value: RootTarget().ID" /></p>
<p>RootTarget.Name: <input data-bind="value: RootTarget().Name" /></p>
<p>TYPE: <input data-bind="value: RootTarget().Filter().Type" /></p>
<hr />
<p>RootTarget.FilterClause Hierarcy</p>
<div data-bind="with: RootTarget().Filter">
<div data-bind="template: { name: 'QueryListClauseTemplate' }"></div>
</div>
<hr />
<p>RootTarget.Selects</p>
<div data-bind="foreach: { data: RootTarget().Selects }">
<div data-bind="template: { name: 'QueryListSelectsTemplate' }"></div>
</div>
</div>
<script type="text/template" id="QueryListClauseTemplate">
<a title="FilterClause.Type" href="#" data-bind="text: Type" />
<div data-bind="foreach: { data: Children }">
<div data-bind="template: { name: 'QueryListClauseTemplate' }"></div>
</div>
</script>
<script type="text/template" id="QueryListSelectsTemplate">
<a title="Select.Name" href="#" data-bind="text: Name" />
</script>
</body>
</html>
Another approach is to create a .d.ts file that defines TypeScript interfaces that describe the nested collections of observable types that are generated by the knockout mapping plugin given your C# classes.
Then you get the type-checking you desire using the .d.ts file (the same way you would use a .d.ts file from the definitely typed github project to get type checking for existing javaScript libraries).
I created a console app to examine my c# dll using reflection. I used a custom attribute to mark the types for which TypeScript interfaces were to be created. (I had to also create a custom attribute to mark which properties were NOT to be created as observable, since the mapping plugin only makes the leaf nodes of your nested collections as observables).
This worked well for me as I was able to regenerate the .d.ts file quickly when my C# model changed. And I was able to have type-checking for all parts of my knockout ViewModel.
//the custom attributes to use on your classes
public class GenerateTypeScript : Attribute
{
public override string ToString()
{
return "TypeScriptKnockout.GenerateTypeScript";
}
}
public class NotObservable : Attribute
{
public override string ToString()
{
return "TypeScriptKnockout.NotObservable";
}
}
//example of using the attributes
namespace JF.Models.Dtos
{
[TypeScriptKnockout.GenerateTypeScript]
public class ForeclosureDetails : IValidatableObject, IQtipErrorBindable
{
[TypeScriptKnockout.NotObservable]
public Foreclosure Foreclosure { get; set; }
//strings used for form input and validation
public string SaleDateInput { get; set; }
public string SaleTimeInput { get; set; }
....etc.
//the console app to generate the .d.ts interfaces
void Main()
{
string dllPath = @"binFolder";
string dllFileName = "JF.dll";
Assembly assembly = Assembly.LoadFrom(Path.Combine(dllPath,dllFileName));
List<string> interfacesToIgnore = new List<string>{"IValidatableObject"}; //stuff that won't exist on the client-side, Microsoft Interfaces
var types = from t in assembly.GetTypes()
where (t.IsClass || t.IsInterface)
&& t.GetCustomAttributes(true).Any( a => ((Attribute)a).ToString() == "TypeScriptKnockout.GenerateTypeScript")
orderby t.IsClass, t.Name
select t;
Console.WriteLine("/// <reference path=\"..\\Scripts\\typings\\knockout\\knockout.d.ts\" />");
foreach (var t in types)
{
//type
Console.Write("{0} {1}", " interface", t.Name);
//base class
if(t.BaseType != null && t.BaseType.Name != "Object"){
Console.Write(" extends {0}", t.BaseType.Name);
}
//interfaces
var interfacesImplemented = t.GetInterfaces().Where (i => !interfacesToIgnore.Contains(i.Name) ).ToList();
if(interfacesImplemented.Count() > 0){
Console.Write(" extends");
var icounter = 0;
foreach (var i in interfacesImplemented)
{
if(icounter > 0)
Console.Write(",");
Console.Write(" {0}", i.Name );
icounter++;
}
}
Console.WriteLine(" {");
//properties
foreach (var p in t.GetProperties())
{
var NotObservable = p.GetCustomAttributes(true).Any(pa => ((Attribute)pa).ToString() == "TypeScriptKnockout.NotObservable" );
Console.WriteLine(" {0}: {1};", p.Name, GetKnockoutType(p, NotObservable));
}
Console.WriteLine(" }\n");
}
}
public string GetKnockoutType(PropertyInfo p, bool NotObservable){
if(p.PropertyType.Name.StartsWith("ICollection")
|| p.PropertyType.Name.StartsWith("IEnumerable")
|| p.PropertyType.Name.StartsWith("Dictionary")
|| p.PropertyType.Name.StartsWith("List"))
{
return String.Format("KnockoutObservableArray<{0}>", p.PropertyType.GenericTypeArguments[0].Name);
}
var typeName = p.PropertyType.Name;
if(typeName.StartsWith("Nullable"))
typeName = p.PropertyType.GenericTypeArguments[0].Name;
switch (typeName)
{
case "Int32" :
case "Decimal" :
return NotObservable ? "number" : "KnockoutObservable<number>";
case "String" :
return NotObservable ? "string" : "KnockoutObservable<string>";
case "DateTime" :
return NotObservable ? "Date" : "KnockoutObservable<Date>";
case "Boolean":
return NotObservable ? "boolean" : "KnockoutObservable<boolean>";
case "Byte[]":
return NotObservable ? "any" : String.Format("KnockoutObservableAny; //{0}", typeName);
default:
if(NotObservable)
return typeName;
bool isObservableObject = true;
var subProperties = p.PropertyType.GetProperties();
foreach (var subProp in subProperties)
{
if(
subProp.PropertyType.IsClass
&& !subProp.PropertyType.Name.StartsWith("String")
&& !subProp.PropertyType.Name.StartsWith("ICollection")
&& !subProp.PropertyType.Name.StartsWith("IEnumerable")
&& !subProp.PropertyType.Name.StartsWith("Dictionary")
&& !subProp.PropertyType.Name.StartsWith("List")
)
{
isObservableObject = false;
}
}
return isObservableObject ? String.Format("KnockoutObservable<{0}>", typeName) : typeName;
}
}
//example of the interfaces generated
interface ForeclosureDetails extends IQtipErrorBindable {
Foreclosure: Foreclosure;
SaleDateInput: KnockoutObservable<string>;
SaleTimeInput: KnockoutObservable<string>;
...etc.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With