Mapping hierarchical JSON to TypeScript-KnockoutJS typed Object
Asked Answered
T

2

9

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": []
                }
            ]
        }
    }
}
Tolle answered 9/7, 2013 at 16:57 Comment(0)
T
7

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>
Tolle answered 10/7, 2013 at 13:52 Comment(3)
Fixed a couple of minor oversights, but still not working as expected.Tolle
Don't seem to be getting far with this question, I guess the TypeScript could be over complicating the root of the question. I have asked another question with the root cause, SEE LINK I will updated this one with a soild solution when I get an answer.Tolle
Turns out my TypeScript was sound!, the problem was incorrect bindings, I was not using parentheses on my observables. LINK TO EXPLAINATIONTolle
B
1

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.
Billye answered 13/12, 2013 at 17:9 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.