Thursday, February 23, 2017

Object-Oriented JavaScript in the UI [2]

Today I'm going to start digging in to the implementation of the first couple of classes in the Jobs-UI object-structure. The classes I'm going to implement are some of the behind-the-scenes items, defining the data-structure of a job, and an object for keeping track of the collection of jobs, adding and removing jobs from that collection, and notifying other objects of changes to the collection. There is also some utility functionality that other classes, particularly the display-oriented ones, will need as well.

A JavaScript class-template

Given my recent attention to Python code-templates, it should maybe come as no great surprise that I also have one for JavaScript classes. I mentioned previously that there are several different ways to define classes in JavaScript, even without the new class and extends syntax-sugar. One of them uses the JavaScript prototype property. A reasonably-complete breakdown of that structure and some of it's permutations can be found on the w3schools website. That works reasonably well, but as far as I've been able to tell, there's no good/real way to emulate private properties and methods. The way that I generally define JavaScript classes, I find that I frequently want private members, so I use what might be called a function-based approach that I know supports them. That approach looks something like this:

function ClassName()
{
    /*
     * TODO: Describe the class (Represents a BLAH, or whatever)
     * 
     * argumentName .... TODO: Describe the argument
     */
    // Processing that needs to happen before definition, if any
    // Instance properties:
    //  + Public properties
//    this.publicName = null;
    //  + Private properties
//    var privateName = null;
    //  + Static properties
//    ClassName.staticName = null;
    // Initialize as needed based on incoming arguments
    // Instance methods:
    //  + public methods
//    this.publicMethod = function()
//    {
//        /*
//         * TODO: Describe the method
//         * 
//         * argumentName ..... TODO: Describe the argument
//         */
//        return;
//    }
    //  + private methods
//    function privateMethod()
//    {
//        /*
//         * TODO: Describe the method
//         * 
//         * argumentName ..... TODO: Describe the argument
//         */
//        return;
//    }
    // Static methods of the instance
//    ClassName.staticMethod = function()
//    {
//        /*
//         * TODO: Describe the method
//         * 
//         * argumentName ..... TODO: Describe the argument
//         */
//        return;
//    }
    // Event-like methods of the instance
//    this.eventMethod = function()
//    {
//        /*
//         * TODO: Describe the method
//         * 
//         * argumentName ..... TODO: Describe the argument
//         */
//        return;
//    }
    // Processing that needs to happen before returning the instance, if any
    // Return the instance
    return this;
}
I've made this template-file available for download — The link is at the end of the post.

The UI Markup

The basic markup for the UI (without any styling) will provide a number of data-* attributes that the UI objects will use to detect elements that serve various purposes once the code executes:

<div id="ui-example">
    <fieldset id="currentJobList">
        <legend>Current Jobs [<span id="jobCounter">job-count</span>]</legend>
        <div>
            <span data-sort="number"><strong>Job Number</strong></span>
            <span data-sort="name"><strong>Job Title</strong></span>
            <span data-sort="contact"><strong>Job Contact</strong></span>
        </div>
        <div id="managedJobsList">
            <div data-template="true">
                <span data-field="number">number</span>
                <span data-field="name">name</span>
                <span data-field="contact">contact</span>
                <button data-action="remove">X</button>
            </div>
        </div>
        <div>
            <label > </label>
            <button data-jobaction="startJob">Add A New Job</button>
        </div>
        <fieldset id="newJobForm">
            <legend>Create a New Job</legend>
            <label>Job Number</label>
            <input name="number" type="text" data-jobfield="number"/>
            <br />
            <label>Job Title</label>
            <input name="title" type="text" data-jobfield="name"/>
            <br />
            <label>Job Contact</label>
            <input name="contact" type="text" data-jobfield="contact"/>
            <br />
            <label> </label>
            <button data-jobaction="createJob">OK</button>
            <button data-jobaction="cancelJob">Cancel</button>
        </fieldset>
    </fieldset>

    <fieldset id="jobNameList">
        <legend>All Jobs</legend>
        <div id="managedNameList">
            <div data-template="true" data-field="name">name</div>
        </div>
    </fieldset>

    <fieldset id="jobContactList">
        <legend>All Job Contacts</legend>
        <div id="managedContactlist">
            <div data-template="true" data-field="contact">contact</div>
        </div>
    </fieldset>
</div>

The data-* attributes in play are:

data-sort
Indicates that the element is a sort-control, and provides the name of the Job field that the control will sort by when it is clicked by the user.
data-template
Indicates that the element and its children are to be used as a markup template by the code, allowing the original markup to define what the structure of the display will look like when it's generated.
data-field
Indicates what field from a Job should be displayed in the element's content.
data-action and data-jobaction
Indicates that the element performs some sort of action in the final, rendered UI. The values of this attribute are tied to specific action sequences, with specific events associated, etc.
The idea of tagging items in the markup to control some aspect of the results of the code is something that experience has shown me to be very useful when a web-application has separate (and differently-focussed) developers for logic and design. Allowing a designer to take near-total control over the appearance of an application's UI requires more thought (and more code), but has almost always been a good time-investment.

There will also be some initialization code that creates the various UI-object instances and initializes them, but I've left that out for now. It will be included in the final file-set that I'll make available for download once this series of posts is complete.

Implementing the Job UI Classes

As noted in the previous post, there are seven different classes involved in this UI codebase. In the interests of keeping post-length down to something reasonable (and readable, I hope), I'm going to break the coverage of them out into several posts (three, I think). I'm also leaving all their comments in — even though that adds to the length of the post (maybe more than I'd like), it'll make it easier to discuss interesting and salient points about them, I hope.

The Job class

Job is a dumb data object that represents a single job in the UI. As such, it's mostly just object-properties and instance-representation (through the toString method).

function Job( parameters )
{
    /*
     * Represents a job.
     * 
     * parameters ...... (object, required) The job's data:
     *  +- number ...... (str, required) The job-number of the job;
     *  +- name ........ (str, required) The name of the job;
     *  +- contact ..... (str, required) The contact-of-record for the job
     */
    // Instance properties:
    //  + Public properties
    this.contact = parameters.contact;
    this.number = parameters.number;
    this.name = parameters.name;
    //  + Static properties
    if( typeof Job.fieldNames == 'undefined' )
    { Job.fieldNames = [ 'contact', 'name', 'number' ]; }
    // Instance methods:
    //  + public methods
    this.toString = function()
    {
        /*
         * TODO: Describe the method
         */
        return '<Job number="' + this.number + '" name="' + 
            this.name + '" contact="' + this.contact + '"/>';
    }
    // Return the instance
    return this;
}

Because a Job's properties are used to generate displayed content across several of the other objects in the UI code, they are all defined as public properties. Those property-values can be read and altered without restriction. In a live-system environment, I might spend some time trying to come up with some way to arrange things so that they could not be set once they were defined — in order to keep the UI code from being able to easily alter them on the fly. It would depend heavily on where those Job objects were coming from, though. If they were being provided in real time from a back-end web-service, for example, I would be much less concerned, since that would imply that the service was responsible for managing the data-integrity of the job-objects.

The JobManager class

The JobManager, as the name implies, keeps track of and (to some degree) manages a collection of Jobs, allowing the addition and removal of individual Job objects. It is also responsible for dispatching messages to other objects (the display-oriented ones) that those objects can use to update their content when a change in the managed Jobs has occurred. JobManager is the subject in an Observer pattern relationship with any number of display-component observers.

function JobManager( jobs )
{
    /*
     * Keeps track of the _jobs for the application/UI
     * 
     * _jobs ............ (array of objects, optional) The data of the _jobs 
     *                   to keep track of.
     */
    // Processing that needs to happen before definition, if any
    if ( typeof JobManager.instance != 'undefined' )
    {
        //  Singleton-ish behavior: There can be only one, and it should live 
        //  in JobManager.instance if it exists, so return it
        return JobManager.instance;
    }
    // Instance properties:
    //  + Private properties
    var _displays = [];
    var _jobs = [];
    //  + Static properties
    // NOTE: JobManager.instance is set at the end of instantiation!
    // Instance methods:
    //  + public methods
    this.addDisplay = function( display )
    {
        /*
         * Adds a display to the instance's collection of _displays to be 
         * notified when the collection of _jobs changes
         */
        if ( typeof display.onJobsChanged == 'function' )
        { _displays.push( display ); }
    }
    this.addJob = function( job, updateNow )
    {
        /*
         * Adds a job to the instance's collection of _jobs
         * job ......... (object, required) The job to add to the collection
         * updateNow ... (bool, optional, defaults to true) Indicates whether 
         *               or not to refresh all job-_displays immediately upon 
         *               completion of adding the job.
         */
        if ( typeof updateNow == 'undefined' )
        { updateNow = true; }
        _jobs.push( new Job( job ) );
        if( updateNow )
        { this.jobsChanged(); }
    }
    this.compareJobs = function( job1, job2 )
    {
        /*
         * Compares two Job (or equivalent) objects, returning true if they have 
         * the same field-values, false otherwise
         */
        result = true;
        for( fi=0; fi<Job.fieldNames.length; fi++ )
        {
            field = Job.fieldNames[ fi ];
            try
            {
                if ( job1[ field ] != job2[ field ] )
                {
                    result = false;
                    break;
                }
            }
            catch( error )
            { result = false; }
        }
        return result;
    }
    this.getSortedJobs = function( sortKey, sortOrder )
    {
        /*
         * Gets a COPY of the main job-list (because each different display may 
         * need a different sort-order!), sorted by the sortField specified, in 
         * the order specified by sortOrder
         */
        // Make a copy of the entire current _jobs array
        results = _jobs.slice();
        // Sort it by the specified field-name using a callback 
        switch( sortKey )
        {
            // Define the callback based on the sort-field specified:
            case 'number':
                sortf = function( a, b )
                    {
                        if ( a.number < b.number )
                        { return -1; }
                        if ( a.number > b.number )
                        { return 1; }
                        return 0;
                    };
                    break;
            case 'name':
                sortf = function( a, b )
                    {
                        if ( a.name < b.name )
                        { return -1; }
                        if ( a.name > b.name )
                        { return 1; }
                        return 0;
                    };
                    break;
            case 'contact':
                sortf = function( a, b )
                    {
                        if ( a.contact < b.contact )
                        { return -1; }
                        if ( a.contact > b.contact )
                        { return 1; }
                        return 0;
                    };
                    break;
        }
        // Sort the results with the callback;
        results.sort( sortf );
        // Reverse the results if the sort-orer is "down"
        if ( sortOrder == 'down' )
        { results.reverse(); }
        return results;
    }
    this.removeDisplay = function( display )
    {
        /*
         * Removes a display from the instance's collection of _displays 
         * to be notified when the collection of _jobs changes
         */
        console.log( 'JobManager.removeDisplay( ' + display + ' ) called' );
        console.log( 'JobManager.removeDisplay complete' );
    }
    this.removeJob = function( job, updateNow )
    {
        /*
         * Removes a job from the instance's collection of _jobs
         */
        if ( typeof updateNow == 'undefined' )
        { updateNow = true; }
        new_jobs = [];
        for( ji=0; ji<_jobs.length; ji++ )
        {
            if ( ! this.compareJobs( _jobs[ ji ], job ) )
            { new_jobs.push( _jobs[ ji ] ); }
        }
        _jobs = new_jobs;
        if( updateNow )
        { this.jobsChanged(); }
    }
    //  + private methods
    // Event-like methods of the instance
    this.jobsChanged = function()
    {
        /*
         * Called when a job is added or removed, calls the onjobsChanged method 
         * of all registered _displays
         */
        for( di=0; di<_displays.length; di++ )
        { _displays[ di ].onJobsChanged(); }
    }
    // Processing that needs to happen before returning the instance, if any
    if ( jobs )
    {
        // Add all the jobs one by one, without changing the displays
        for ( ji=0; ji<jobs.length; ji++ )
        { this.addJob( jobs[ ji ], false ); }
    }
    // Store the instance in JobManager.instance, and return that
    JobManager.instance = this;
    return JobManager.instance;
}

I've defined JobManager as a Singleton, of sorts: There can be only one active instance of the class in a page, and any attempts to generate an instance will either create the first instance (which happens during the execution of the set-up code), or will return that first instance, already populated and active. You'll see that I leverage this behavior in several of the other classes in the codebase, allowing the page to create a JobManager and populate it with the Jobs for the page, then calling new JobManager() to set up a local reference to the original JobManager in instances of the other classes. The real instance is created and stored as a nominally-static property of the JobManager class (JobManager.instance at the end of the object's definition before being returned.

The two properties of JobManager, _displays and _jobs are private. I could not come up with any reason why any other object in this codebase would need to know anything about either of them, other than the need to acquire a sequence of Jobs for display purposes, and that has additional wrinkles that were better handled by a method (more on that later).

The methods of JobManager are, I think, pretty straightforward:

addDisplay( display ) and removeDisplay( display )
Allow the addition (and registration) and removal of observer display-management objects. Once added, any time the jobsChanged() method of the instance is called, all of the registered displays will be notified that they need to update because there's been a change in the Jobs that the JobManager is keeping track of.
addJob( job, updateNow ) and removeJob( job, updateNow )
Provide a mechanism for adding or removing a single Job from the collection of Jobs that the JobManager is keeping track of. The updateNow argument (which defaults to a true value) allows a developer to write code that adds a series of Jobs to the instance, then explicitly calling for an update (with jobsChanged(). That process, minus the explicit call to jobsChanged(), can be seen in the code at the end of the object-definition.
compareJobs( job1, job2 )
A helper-method (used in removeJob) that compares two objects (which may or may not be Job instances, but frequently are), returning true if both objects provided have the same values for their contact, name and number properties. In practice, I've found that one of the two objects being compared is frequently not a Job instance, though in those cases, it has always been a generic object with the same properties as a Job.
getSortedJobs( sortKey, sortOrder )
Returns a sequence of Job objects, sorted by the sortKey property-values of those objects, in the sortOrder direction. This was needed because each display-object in the codebase can have its own sort-criteria:
  • JobContactList will always sort by contact, ascending;
  • JobNameList will always sort by name, ascending; and
  • JobListView can sort by any field, in either direction
getSortedJobs, then, allows each of those display-objects to use their own separate sort-criteria and -order, no matter what the other objects are using.
jobsChanged()
Calls the onJobsChanged methods of all registered _displays.

In my next post, I'll tackle the various display-object classes: JobListView, JobContactList, JobNameList and JobCounter. Before I break for the day, though, here's a download-link for my JavaScript class-template file:

No comments:

Post a Comment