Search code examples
typescriptknockout-2.0knockout-mapping-pluginknockout-mvc

Mapping hierarchical JSON to TypeScript-KnockoutJS typed Object


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": []
                }
            ]
        }
    }
}

Solution

  • 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>