13

I want to rewrite the JavaScript "method" further below in TypeScript. I am tempted to do it as class, like this:

// export default class 
export default class GroupItemMetadataProvider1
{
    protected m_instance;
    protected _grid;
    protected _defaults;

    protected m_options;
    constructor(options)
    {
        this.m_instance = this;
        
        this._defaults = {
            groupCssClass: "slick-group",
            groupTitleCssClass: "slick-group-title",
            totalsCssClass: "slick-group-totals",
            groupFocusable: true,
            totalsFocusable: false,
            toggleCssClass: "slick-group-toggle",
            toggleExpandedCssClass: "expanded",
            toggleCollapsedCssClass: "collapsed",
            enableExpandCollapse: true,
            groupFormatter: this.defaultGroupCellFormatter,
            totalsFormatter: this.defaultTotalsCellFormatter
        };

        options = $.extend(true, {}, this._defaults, options);
        this.m_options = options;
    }

    
    protected defaultGroupCellFormatter(row, cell, value, columnDef, item)
    {
        if (!this.m_options.enableExpandCollapse)
        {
            return item.title;
        }

        let indentation = item.level * 15 + "px";

        return "<span class='" + this.m_options.toggleCssClass + " " +
            (item.collapsed ? this.m_options.toggleCollapsedCssClass : this.m_options.toggleExpandedCssClass) +
            "' style='margin-left:" + indentation + "'>" +
            "</span>" +
            "<span class='" + this.m_options.groupTitleCssClass + "' level='" + item.level + "'>" +
            item.title +
            "</span>";
    }


    protected defaultTotalsCellFormatter(row, cell, value, columnDef, item)
    {
        return (columnDef.groupTotalsFormatter && columnDef.groupTotalsFormatter(item, columnDef)) || "";
    }


    protected init(grid)
    {
        this._grid = grid;
        this._grid.onClick.subscribe(this.handleGridClick);
        this._grid.onKeyDown.subscribe(this.handleGridKeyDown);
    }


    protected destroy()
    {
        if (this._grid)
        {
            this._grid.onClick.unsubscribe(this.handleGridClick);
            this._grid.onKeyDown.unsubscribe(this.handleGridKeyDown);
        }
    }


    protected handleGridClick(e, args)
    {
        let context = (<any>this);

        let item = context.getDataItem(args.row);
        if (item && item instanceof Slick.Group && $(e.target).hasClass(this.m_options.toggleCssClass))
        {
            let range = this._grid.getRenderedRange();
            context.getData().setRefreshHints({
                ignoreDiffsBefore: range.top,
                ignoreDiffsAfter: range.bottom + 1
            });

            if (item.collapsed)
            {
                context.getData().expandGroup(item.groupingKey);
            } else
            {
                context.getData().collapseGroup(item.groupingKey);
            }

            e.stopImmediatePropagation();
            e.preventDefault();
        }

    }


    // TODO:  add -/+ handling
    protected handleGridKeyDown(e)
    {
        let context = (<any>this);

        if (this.m_options.enableExpandCollapse && (e.which == Slick.keyCode.SPACE))
        {
            let activeCell = context.getActiveCell();
            if (activeCell)
            {
                let item = context.getDataItem(activeCell.row);
                if (item && item instanceof Slick.Group)
                {
                    let range = this._grid.getRenderedRange();
                    context.getData().setRefreshHints({
                        ignoreDiffsBefore: range.top,
                        ignoreDiffsAfter: range.bottom + 1
                    });

                    if (item.collapsed)
                    {
                        context.getData().expandGroup(item.groupingKey);
                    } else
                    {
                        context.getData().collapseGroup(item.groupingKey);
                    }

                    e.stopImmediatePropagation();
                    e.preventDefault();
                }
            }
        }
        
    }

    public getGroupRowMetadata(item)
    {
        return {
            selectable: false,
            focusable: this.m_options.groupFocusable,
            cssClasses: this.m_options.groupCssClass,
            columns: {
                0: {
                    colspan: "*",
                    formatter: this.m_options.groupFormatter,
                    editor: null
                }
            }
        };
    }

    public getTotalsRowMetadata(item)
    {
        return {
            selectable: false,
            focusable: this.m_options.totalsFocusable,
            cssClasses: this.m_options.totalsCssClass,
            formatter: this.m_options.totalsFormatter,
            editor: null
        };
    }
}

However, handleGridClick & handleGridKeyDown both reference the 'this' "event-context", as well as the 'this' class-context to get the options.

Problem is, I can't just bind the this in the constructor, because otherwise, the this-context of the object is wrong. How can I do this?

This is the plain JavaScript variant:

// import $ from '../../wwwroot/jQuery-3.3.js';
import Slick from './slick.core.js';

export default GroupItemMetadataProvider;

/***
 * Provides item metadata for group (Slick.Group) and totals (Slick.Totals) rows produced by the DataView.
 * This metadata overrides the default behavior and formatting of those rows so that they appear and function
 * correctly when processed by the grid.
 *
 * This class also acts as a grid plugin providing event handlers to expand & collapse groups.
 * If "grid.registerPlugin(...)" is not called, expand & collapse will not work.
 *
 * @class GroupItemMetadataProvider
 * @module Data
 * @namespace Slick.Data
 * @constructor
 * @param options
 */
function GroupItemMetadataProvider(options)
{
    let _grid;
    let _defaults = {
        groupCssClass: "slick-group",
        groupTitleCssClass: "slick-group-title",
        totalsCssClass: "slick-group-totals",
        groupFocusable: true,
        totalsFocusable: false,
        toggleCssClass: "slick-group-toggle",
        toggleExpandedCssClass: "expanded",
        toggleCollapsedCssClass: "collapsed",
        enableExpandCollapse: true,
        groupFormatter: defaultGroupCellFormatter,
        totalsFormatter: defaultTotalsCellFormatter
    };

    options = $.extend(true, {}, _defaults, options);

    function defaultGroupCellFormatter(row, cell, value, columnDef, item)
    {
        if (!options.enableExpandCollapse)
        {
            return item.title;
        }

        let indentation = item.level * 15 + "px";

        return "<span class='" + options.toggleCssClass + " " +
            (item.collapsed ? options.toggleCollapsedCssClass : options.toggleExpandedCssClass) +
            "' style='margin-left:" + indentation + "'>" +
            "</span>" +
            "<span class='" + options.groupTitleCssClass + "' level='" + item.level + "'>" +
            item.title +
            "</span>";
    }

    function defaultTotalsCellFormatter(row, cell, value, columnDef, item)
    {
        return (columnDef.groupTotalsFormatter && columnDef.groupTotalsFormatter(item, columnDef)) || "";
    }

    function init(grid)
    {
        _grid = grid;
        _grid.onClick.subscribe(handleGridClick);
        _grid.onKeyDown.subscribe(handleGridKeyDown);
    }

    function destroy()
    {
        if (_grid)
        {
            _grid.onClick.unsubscribe(handleGridClick);
            _grid.onKeyDown.unsubscribe(handleGridKeyDown);
        }
    }

    function handleGridClick(e, args)
    {
        let item = this.getDataItem(args.row);
        if (item && item instanceof Slick.Group && $(e.target).hasClass(options.toggleCssClass))
        {
            let range = _grid.getRenderedRange();
            this.getData().setRefreshHints({
                ignoreDiffsBefore: range.top,
                ignoreDiffsAfter: range.bottom + 1
            });

            if (item.collapsed)
            {
                this.getData().expandGroup(item.groupingKey);
            } else
            {
                this.getData().collapseGroup(item.groupingKey);
            }

            e.stopImmediatePropagation();
            e.preventDefault();
        }
    }

    // TODO:  add -/+ handling
    function handleGridKeyDown(e)
    {
        if (options.enableExpandCollapse && (e.which == Slick.keyCode.SPACE))
        {
            let activeCell = this.getActiveCell();
            if (activeCell)
            {
                let item = this.getDataItem(activeCell.row);
                if (item && item instanceof Slick.Group)
                {
                    let range = _grid.getRenderedRange();
                    this.getData().setRefreshHints({
                        ignoreDiffsBefore: range.top,
                        ignoreDiffsAfter: range.bottom + 1
                    });

                    if (item.collapsed)
                    {
                        this.getData().expandGroup(item.groupingKey);
                    } else
                    {
                        this.getData().collapseGroup(item.groupingKey);
                    }

                    e.stopImmediatePropagation();
                    e.preventDefault();
                }
            }
        }
    }

    function getGroupRowMetadata(item)
    {
        return {
            selectable: false,
            focusable: options.groupFocusable,
            cssClasses: options.groupCssClass,
            columns: {
                0: {
                    colspan: "*",
                    formatter: options.groupFormatter,
                    editor: null
                }
            }
        };
    }

    function getTotalsRowMetadata(item)
    {
        return {
            selectable: false,
            focusable: options.totalsFocusable,
            cssClasses: options.totalsCssClass,
            formatter: options.totalsFormatter,
            editor: null
        };
    }

    function getOptions()
    {
        return options;
    }


    return {
        init,
        destroy,
        getGroupRowMetadata,
        getTotalsRowMetadata,
        getOptions
    };
}
Brian Tompsett - 汤莱恩
  • 5,753
  • 72
  • 57
  • 129

4 Answers4

4

This is more of a javascript than a typescript question.

In example 1 you are trying to use a "class pattern", in example 2 you use something like a "closure class" (theres a name for that pattern which I don't remember).

Both patterns are writeable in TS, and I personally prefer to keep "closure class" (example 2). So, you can just keep your code and add type annotations. Turn on strict: true and give type annotations to whatever the compiler yells you "has an implicit any type".

personal opinion ahead

Pattern nr.2 is usually more maintanable (sources?), pattern 1 is harder to refactor, requires more type annotations, more thinking and gives you room for the this binding issue. You'd still want to use pattern 1 on performance intensive things (ex: a game, which seems not to be your case), other than that, pattern 2 is your go. Even classic OOP cases (extend a class and override a method) are easily obtainable through option 2 (option bag pattern).

Typescript's type system is structural -- quite more powerful than "classic" java/C# -- and classes are not nominal in TS. Those are 2 more reasons not to use classes. class A {} and class B {} or any object are assignable if they have the same properties.

EDIT: About the this binding issue

If you really want to stick to ehh... classes...

  • You can't have your this be 2 things at the same time. If your this is your class, then you can find your element through event.target. If your this was rebound to an element... Oh, well.

  • So you'll have to call something like element.addEventListener("click", Instance.doSomething.bind(this)). addEventListener rebinds your function's this. .bind says: no.

  • Or element.addEventListener("click", (...i) => Instance.doSomething(...i))

  • If your method is really meant to be called from another this context, then write something like

    method(this: HTMLInputElement, x: number, y: string) { }

this is nothing more than sort of a hidden function parameter (python and lua explicitly send this as a 1st parameter, for instance), which is overriden by the onX calls, which is one of the JS billion-dollar problems, which is one of the reasons why JS classes suck.

wkrueger
  • 1,281
  • 11
  • 21
0

Not entirely sure I have understood your problem correctly but I think you are saying the event handler is referencing the wrong object.

You need to save your context/scope outside of the event, then you can reference it inside, like this

class GroupItemMetadataProvider1
{
    function init(grid)
    {
        let context = this;
        _grid = grid;
        _grid.addEventListener('click', (e, args) => context.handleGridClick(e, args));
        _grid.onKeyDown.subscribe(handleGridKeyDown);
    }

    function handleGridClick(e, args)
    {
        console.log(this); // will reference GroupItemMetadataProvider1
    }
}
nilsolofsson
  • 96
  • 2
  • 6
0

Use can arrow function that captures this from the declaration context, and pass in the event context as an argument using a helper function. The helper function will take the event to subscribe to and will push the subscription in an array to make is simple to unsubscribe from all events.

subscriptions: Array<{ unsubscribe: () => any; }> = []
bindAndSubscribe<TArg1, TArg2>(target: {
    subscribe(fn: (e: TArg1, data: TArg2) => any)
    unsubscribe(fn: (e: TArg1, data: TArg2) => any)
}, handler: (context: any, e: TArg1, arg: TArg2) => void) {
    let fn = function (e: TArg1, arg: TArg2) { handler(this, e, arg) };
    target.subscribe(fn);
    this.subscriptions.push({
        unsubscribe: () => target.unsubscribe(fn)
    });

}
protected init(grid: Slick.Grid<any>)
{
    this._grid = grid;
    // note paramters a and e are inffred correctly, if you ever want to add types
    this.bindAndSubscribe(this._grid.onClick, (c, e, a)=> this.handleGridClick(c, e, a));
    this.bindAndSubscribe(this._grid.onKeyDown, (c,e, a) => this.handleGridKeyDown(c,e));
}


protected destroy()
{
    if (this._grid)
    {
        this.subscriptions.forEach(s=> s.unsubscribe());
    }
}



protected handleGridClick(context, e, args)
{ 
    // correct this
    this.m_options.toggleCssClass
    //...
}


protected handleGridKeyDown(context, e)
{
    // Correct this, context is a parameter
    //...
}

You can also declare the handlers directly as arrow functions, either approach will work:

protected init(grid: Slick.Grid<any>)
{
    this._grid = grid;
    // Arrow function not needed here anymore, the handlers capture this themselves.
    this.bindAndSubscribe(this._grid.onClick, this.handleGridClick);
    this.bindAndSubscribe(this._grid.onKeyDown, this.handleGridKeyDown);
}




protected handleGridClick = (context, e, args) => 
{ 
    // correct this
    this.m_options.toggleCssClass
    //...
}


protected handleGridKeyDown = (context, e) => 
{
    // Correct this, context is a parameter
    //...
}
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
0

As explained in this related ES6 answer, this problem persists in legacy JavaScript libraries (D3, etc.) that rely on dynamic this in event handlers instead of passing all necessary data as arguments.

Same solutions apply to TypeScript as well, with a necessity to maintain type safety.

One solution is to use old self = this recipe that was deprecated with ES6 arrows but is still needed in this situation and has some smell:

handleGridKeyDown(context: DynamicThisType, e: any) {
    // class instance is available as `this`
    // dynamic `this` is available as `context` param
}

...

const self = this;
this._grid.onKeyDown.subscribe(function (this: DynamicThisType, e: any) {
  return self.handleGridKeyDown(this, e);
});

Another solution is to apply this recipe to certain methods with TypeScript decorator, this also results in a method that has class instance as this and dynamic this as context parameter:

function bindAndPassContext(target: any, prop: string, descriptor?: PropertyDescriptor) {
    const fn = target[prop];

    return {
        configurable: true,
        get: function () {
            const classInstance = this;
            function wrapperFn (...args) {
                return fn.call(classInstance, this, ...args);
            }

            Object.defineProperty(this, prop, {
                configurable: true,
                writable: true,
                value: wrapperFn
            });

            return wrapperFn;
        }
    };
}

...

@bindAndPassContext
handleGridKeyDown(context: DynamicThisType, e: any) {
    // class instance is available as `this`
    // dynamic `this` is available as `context` param
}


...

this._grid.onKeyDown.subscribe(this.handleGridKeyDown);
Estus Flask
  • 206,104
  • 70
  • 425
  • 565