Autowire.js

/**
 * Attach javascript classes to HTML Nodes by subclassing Autowire. 
 *
 * Some convenience features (like easy fetching) are included.
 *
 */
Autowire = class {

    /**
     *
     * @param node a node to attach to or querySelector to create a new node from an existing template node
     * @param args to pass to onCreate(){} and onAttach(){}
     */
    constructor(node, ...args){
        this.readyCalled = false;
        
        /**
         * Class name
         */
        this.name = this.constructor.name;
        /**
         * Node this class is attached to
         */
        this.node = node;
        /**
         * Alias for this.node
         */
        this.n = node;

        this.onCreate(...args);
        this.constructor.attach(this);
        this.onAttach(...args);
    }

    /**
     * shorthand for this.node.innerHTML;
     * @tag shorthand
     */
    get innerHTML(){
        return this.node.innerHTML;
    }
    /**
     * shorthand for this.node.innerHTML = value;
     * @tag shorthand
     */
    set innerHTML(value){
        this.node.innerHTML = value;
    }

    /**
     * Initialize your class BEFORE listeners are setup, AFTER this.node is set
     *
     * @param ...args any args passed to the constructor
     */
    onCreate(){}

    /**
     * Initialize your class AFTER listeners are setup. 
     *
     * @param ...args any args passed to the constructor
     */
    onAttach(){}

    /**
     * Called when all pending Autowire attachments have been completed. 
     * This is NOT called if you instantiate directly (`new MyAutowireSubclass(node)`) 
     *
     * @param ...args any args that were passed to the constructor
     */
    onReady(){}

    /**
     *
     * Get an object that's a descendent of this object. 
     * If this object contains multiple descendents, use `getAll()`
     *
     * @param object_class_name string class name of the autowire sub-class
     *
     * @return Autowire subclass object or null 
     */
    getObject(object_class_name){
        return this.getAll(object_class_name)[0] || null;
    }

    /**
     *
     * Get all objects descending from this object. 
     *
     * @param object_class_name string class name of the autowire sub-class
     *
     * @return array of autowire subclass objects. May be empty array.
     */
    getObjects(object_class_name){
        return this.constructor.getObjectsFromName(object_class_name,this);
    }

    /**
     * Get objects anywhere in the dom (not necessarily children)
     *
     * @param object_class_name string the class name of an autowire subclass
     * @return array an array of autowire objects
     */
	getGlobalObjects(object_class_name){
		return this.constructor.getObjectsFromName(object_class_name);
	}

    /**
     * Wire the called class class to the target nodes. (one instance for each node)
     *
     * @param querySelector string query selector of one or multiple nodes.
     * @param onPageLoad bool true to attach after page has loaded. Will attach immediately if page has already loaded. default is true
     * @param ...args array arguments to pass to onCreate(...args) and onReady(...args)
     *
     * @return void
     */
    static autowire(querySelector=null, onPageLoad=true, ...args) {
        Autowire.readyCount++;
        if (onPageLoad){
            this.onPageLoad(this.attachSelector,this,querySelector,true,...args);
        } else {
            this.attachSelector(querySelector,false,...args);
        }
    }

    /**
     * Immediately attach this class to the target nodes. 
     *
     * Calls `onReady()` if the autowire queue is empty.
     *
     * @param querySelector a query selector
     * @param ...args array arguments to pass to onCreate(...args) and onReady(...args)
     *
     * @return void
     */
    static attachSelector(querySelector, ...args){
        Autowire.readyCount--;
        let aw = null;
        for (const node of document.querySelectorAll(querySelector)){
            if (this.getObjectFromNode(node)!==null)continue;
            aw = new this(node, ...args);
            Autowire.waitingForReady.push(aw);
        }
        if (Autowire.readyCount==0){
            this.readyUpAllObjects();
        }
    }

    /**
     * Clone a template and attach a new instance of this class to it. 
     *
     * If non-template: Simply clone the node
     * If template: Clone the template's content node. If there are multiple root nodes, they'll be wrapped in a <div>
     *
     * @param querySelector string query selector to find the template
     *
     * @return Autowire instance/subclass
     */
    static attachTemplate(querySelector, ...args){
        let node = null;
        const template = document.querySelector(querySelector);
        if (template == null){
            console.error(querySelector + " is not a valid query selector");
            return null;
        }
        
        if (template.tagName.toUpperCase()=='TEMPLATE'){
            if (template.content.children.length>1){
                node = document.createElement('div');
                node.appendChild(template.content.cloneNode(true));
            } else {
                node = template.content.children[0].cloneNode(true);
            }
        } else {
            node = template.cloneNode(true);
        }

        const Obj = new this(node, ...args);
        return Obj;
    }

    /**
     * Fetch json from a web request
     *
     * @param url string a url
     * @param params array<string key, mixed value> of paramaters 
     * @param method the http verb, like POST, GET, PUT, etc. POST is default
     *
     * @return object any object representable by json.
     */
    async fetchJson(url, params=[], method="POST"){
        const res = await this.fetch(url,params,method);
        const json = await res.json();
        return json;
    }

    /**
     * Fetch text from a web request
     *
     * @param url string a url
     * @param params array<string key, mixed value> of paramaters 
     * @param method the http verb, like POST, GET, PUT, etc. POST is default
     *
     * @return string text from the response
     */
    async fetchText(url, params=[], method="POST"){
        const res = await this.fetch(url,params,method);
        const text = await res.text();
        return text;
    }
    /**
     * Fetch a url & get a Response
     *
     * @param url string a url
     * @param params array<string key, mixed value> of paramaters 
     * @param method the http verb, like POST, GET, PUT, etc. POST is default
     *
     * @return Response object
     */
    async fetch(url, params, method="POST"){
        var formData = new FormData();
        this.constructor.addParamsToFormData(formData, params);
        const result = await fetch(url, {
            method: method, 
            mode: "cors",
            body: formData
        })
        return result;
    }

    /**
     * Add Params to a FormData object for submission
     *
     * @param formData FormData object
     * @param params array<string key, mixed value> of paramaters 
     */
    static addParamsToFormData(formData, params){
        for(var key in params){
            const param = params[key];
            // if (typeof param == typeof []){
            if (Array.isArray(param)){
                key = `${key}[]`;
                for(const val of param){
                    if (typeof [] === typeof val){

                    } else {
                        formData.append(key,val);
                    }
                }
            } else formData.append(key,params[key]);
        }
    }

    /**
     * Get all objects for the given name
     *
     * @param object_class_name string class name or null to use the called-class's name.
     * @param parentObj An Autowire object (not a node). Its a reference object to only get children, or null to search full DOM
     *
     * @return array<int index, Autowire autowire_subclass>
     */
    static getObjects(object_class_name=null,parentObject=null){ 
        
        object_class_name = object_class_name || this.prototype.constructor.name;
        const all = [];
        for (const item of Autowire.wiredList){
            if (item.obj.constructor.name == object_class_name){
                all.push(item.obj);
            }
        }
		if (parentObject==null)return all;
		const parentNode = parentObject.node;
		const filtered = [];
		for (const child of all){
			let check = child.node;
			do {
				check = check.parentNode;
				if (check==parentNode){
					filtered.push(child);
				}
			}
			while (check!=document.body&&check!=parentNode);
		}
        return filtered;
    }

    /**
     * Get the autowire object attached to a node
     *
     * @param node Node a DOM Node
     * @return Autowire object or null
     */
    static getObjectFromNode(node){
        for (const row of Autowire.wiredList){
            if (row.node===node)return row.obj;
        }
        return null;
    }

    /**
     * Call onReady() on all wired nodes that have not previously readied up
     *
     */
    static readyUpAllObjects(){
        const waiting = Autowire.waitingForReady;
        Autowire.waitingForReady = [];
        for (const aw of waiting){
            if (aw.readyCalled)continue;
            aw.readyCalled = true;
            aw.onReady();
        }
    }

    /**
     * Setup event listeners on the attached node.
     *
     * @param autowire_object Autowire subclass instance
     */
    static attach(autowire_object){
        Autowire.wiredList.push({'node':obj.node,'obj':obj});

        for (const methodName of this.getMethods(obj)){
            if (typeof obj[methodName] !== 'function'
                ||methodName.substring(0,2)!=='on'
                ||methodName=='onCreate'
                ||methodName=='onAttach'
                ||methodName=='onReady')continue;
            const method = obj[methodName].bind(obj);
            const eventName = methodName.substring(2);
            obj.node.addEventListener(eventName,method);
        }
    }

    /**
     * Run the function on page load, or immediately if page is already loaded
     *
     * @param func a function
     * @param thisArg what `this` should refer to inside `func`
     * @pram ...args array of args to pass to func
     *
     * @return void
     */
    static onPageLoad(func, thisArg, ...args) {
        if (document.readyState == 'interactive' || document.readyState == 'complete') {
            func.apply(thisArg, args);
        } else if (document.addEventListener != null) {
            document.addEventListener("DOMContentLoaded", function () {
                if (document.readyState == "interactive") {
                    func.apply(thisArg, args);
                }
            });
        } else {
            window.onload = function () {
                func.apply(thisArg, args);

            };
        }
    }

    /**
     * Get an array of methods from a javascript object
     *
     * @param javascript_object object
     * @return array<int index, string method_name>
     */
    static getMethods(javascript_object){
        const properties = new Set();
        let currentObj = javascript_object;
        do {
            Object.getOwnPropertyNames(currentObj).map(item => properties.add(item))
        } while ((currentObj = Object.getPrototypeOf(currentObj)))
        return [...properties.keys()].filter(item => typeof javascript_object[item] === 'function')

    }
}

/** int number of querySelectors waiting to be executed & wired to nodes */
Autowire.readyCount = 0;
/** array<int index, Autowire autowire_subclass> objects waiting for onReady() to be called. */
Autowire.waitingForReady = [];
/** array<int index, array wired_item> where wired_item contains key 'node' referring to DOM Node and 'obj' referring to Autowire subclass object. */
Autowire.wiredList = [];