Tuesday, February 28, 2017

Object-Oriented JavaScript in the UI [3]

Today I'm going to dive into the code for implementing the display-oriented classes: JobListView, JobContactList, JobNameList and JobCounter.

Some Commonalities in these Classes

Given that three of these four classes (all but JobCounter) have common intentions, it may not be surprising that they share some common implementation details. Those common intentions (in JobListView, JobContactList and JobNameList) boil down to generating a list of elements based on the current Jobs in the JobManager. The resulting interface/members that they share (barring implementation details) are:

Properties
_jobManager [private]
A reference to the JobManager that provides the collection of Jobs that the instance will display.
_listElement [private]
A reference to the page-element where the display's list of items will be shown. Since the contents of this element will be completely replaced when JobManager tells all of the display-elements that an update is needed, it has to be specifically identified, and should be free of any content that isn't related directly to the list-items that will be housed there.
_listItemTemplate [static]
The markup (as a string) that will be used as a template for generating the display-elements for a list-item.
I've set usable default template-string values in the _listItemTemplate properties of each class, but they are overridden by code in the initialization of the class, if template markup can be found.
sortField
The name of the Job property that will be used to sort Jobs for display in the UI that the instance controls.
sortOrder
The sort-order (up or down for ascending and descending, respectively) that will be used for display in the UI that the instance controls.
_top [private]
A reference to the top-most page-element that contains the entire job-object UI that the instance relates to. Note: this might not be the outer-most element of all the collected UI markup, though it could be. In most cases, this will be the wrapper tag of the UI for the instance (that is, the outermost tag that contains the markup, templates, etc., for a JobListDisplay, but not the wrapper for the separate JobContactList or JobNameList UI). This is mostly needed so that the objects can find and identify elements that have relevance to what they control.
Methods
clearDisplay()
Removes all content from the _listElement element.
displayItems()
Populates the _listElement element with the current sequence of Job displays, retrieved by getSortedJobs and formatted by getJobDisplay.
getJobDisplay( job )
Generates DOM-elements using the template markup of _listItemTemplate, and populating the placeholders therein with data from the supplied job.
setListItemTemplate( template ) [static]
Sets the markup to be used to generate display-markup in the static _listItemTemplate property.
onJobsChanged [event]
Called by JobManager.jobsChanged, this is the method that refreshes the job-item list in _listElement.
I toyed briefly with the idea of creating a base class that contained all of these members, then attaching their functionality to the concrete classes with an apply call. After giving it a bit of thought, I opted not to do this, partly because I didn't want to take the time to explore whether the private property-members would still work as I wanted. Since this code is also more of a POC, it didn't seem needful either. If I were to implement this for a live system, I'd probably want to do the research/exploration, though — and if the private-member concern were resolved to my satisfaction, I'd likely implement that way, if only to try and keep common code in a common location. On top of that, I expect that I'd want to discuss that in a fair amount of detail, and I do want to get back to the Python codebase soon, since that's my main focus for the moment.

One thing that all of today's classes do share is that they are all subjects in an Observer-pattern relationship with the JobManager. As a result, the initialization-code for each of them will register the instance as it's created with JobManager.addDisplay.

The JobListView class

JobListView attaches to the main job-list view, this part of the UI:

Current Jobs [# of jobs displayed here]
Job Number Job Title Job Contact
Job-Number #1 Job-Title #1 Job-Contact #1
Job-Number #2 Job-Title #2 Job-Contact #2
...
Job-Number #10 Job-Title #10 Job-Contact #10
It also needs to be able to set up sort-controls (the Job Number, Job Title and Job Contact headers) to allow the user to sort by those values, and to toggle the sort-order between up and down sorts. Its implementation is:

function JobListView( parameters )
{
    /*
     * Provides a list-view of all job data that responds to changes in the jobs 
     * tracked by the UI, includes the ability to sort the items in the display 
     * up or down by the fields of the items.
     * 
     * parameters ...... (object, required) The job's data:
     *  + listElement .. (str|element) The element (or its ID) that will contain
     *                   the list-items, which will be cleared of all child nodes
     *                   and re-populated as needed/arbitrarily.
     *  + sortField .... (str) The name of the Job data-field to be used for 
     *                   sorting list-members in the display.
     *  + sortOrder .... (str: "up" | "down") The sort-order to be used for 
     *                   sorting list-members in the display.
     *  + top .......... (str|element) The element (or its ID) that wraps ALL of 
     *                   the UI managed by this object.
     */
    // Processing that needs to happen before definition, if any
    if ( typeof parameters.listElement == 'string' )
    { parameters.listElement = document.getElementById( parameters.listElement ); }
    if ( typeof parameters.sortField == 'undefined' )
    { parameters.sortField = 'name'; }
    if ( typeof parameters.sortOrder == 'undefined' )
    { parameters.sortOrder = 'up'; }
    if ( typeof parameters.top == 'string' )
    { parameters.top = document.getElementById( parameters.top ); }
    // Instance properties:
    //  + Public properties
    this.sortControls = [];
    this.sortField = parameters.sortField;
    this.sortOrder = parameters.sortOrder;
    //  + Private properties
    var _jobManager = new JobManager();
    var _listElement = parameters.listElement;
    var _top = parameters.top;
    //  + Static properties
    JobListView._listItemTemplate = '<div>Number: <span data-field="number">number</span>; Number: <span data-field="name">name</span>; Contact: <span data-field="contact">contact</span></div>';
    // Initialize as needed based on incoming arguments
    // Instance methods:
    //  + public methods
    this.clearDisplay = function()
    {
        /*
         * Removes all child element from the list-view
         */
        while( _listElement.lastChild )
        { _listElement.removeChild( _listElement.lastChild ); }
    }
    this.displayItems = function()
    {
        /*
         * Populates the listElement with zero-to-many job-displays using the 
         * jobManager's jobs and the current sort-field and -order.
         */
        // Get the jobs that we're concerned with showing
        displayJobs = jobManager.getSortedJobs( this.sortField, this.sortOrder );
        // Generate display markup for each job
        for ( ji=0; ji<displayJobs.length; ji++ )
        {
            children = this.getJobDisplay( displayJobs[ ji ] );
            for( ci=0; ci<children.length; ci++ )
            { _listElement.appendChild( children[ ci ] ); }
        }
    }
    this.getJobDisplay = function( job )
    {
        /*
         * Generates and returns a sequence of nodes based on the class-level 
         * HTML template, populated with the specified job's data, ready to be 
         * appended to the list-display element.
         * 
         * job .............. (Job or equivalent object, required) The job to 
         *                    generate the display for
         */
        // Generate a look-up of nodes by data-name
        dataChildren = {};
        for( dci=0; dci<Job.fieldNames.length; dci++ )
        { dataChildren[ Job.fieldNames[ dci ] ] = null; }
        // Create the wrapper-node that will be used to perform all the DOM 
        // examination and manipulation, and populate it with the template.
        wrapper = document.createElement( 'results' )
        wrapper.innerHTML = JobListView._listItemTemplate;
        // Find all the data-display nodes and keep track of them.
        allChildren = wrapper.getElementsByTagName( '*' );
        for( ci=0; ci<allChildren.length; ci++ )
        {
            node = allChildren[ ci ];
            dataField = node.getAttribute( 'data-field' );
            if( dataField )
            {
                dataChildren[ dataField ] = node;
                // Remove the data-field attribute so as not to clutter the
                // final markup with it...
                node.removeAttribute( 'data-field' );
            }
            dataAction = node.getAttribute( 'data-action' );
            switch( dataAction )
            {
                case 'remove':
                    node.job = job;
                    node.onclick = function()
                        {
                            jobManager = new JobManager();
                            jobManager.removeJob( this.job );
                        }
            }
        }
        // Populate the nodes' innerHTML with the job's values.
        for( dci=0; dci<Job.fieldNames.length; dci++ )
        {
            if ( dataChildren[ Job.fieldNames[ dci ] ] )
            { dataChildren[ Job.fieldNames[ dci ] ].innerHTML = job[ Job.fieldNames[ dci ] ]; }
        }
        // Return the sequence of nodes
        return wrapper.childNodes;
    }
    this.registerSortControl = function( element, field )
    {
        /*
         * Registers a sort-control element with the instance
         *
         * element .......... (str or element) The element or the ID of the element to register as a sort-control for the instance.
         * field ............ (str, required) The field-name that the sort-control will use to specify the sort-order
         */
        if ( typeof element == 'string' )
        { element = document.getElementById( element ); }
        // The element needs to keep track of what field and order it's got
        element.sortField = field;
        element.sortOrder = 'up';
        // The element will need a handle to the JobListView instance, so...
        element.display = this;
        // Make the cursor over it into a pointer, just because
        element.style.cursor = 'pointer';
        // Set up the click-event.
        element.onclick = function()
        {
            if ( this.display.sortField != this.sortField )
            { this.sortOrder = 'up'; }
            else
            {
                if ( this.sortOrder == 'down' )
                { this.sortOrder = 'up'; }
                else
                { this.sortOrder = 'down'; }
            }
            this.display.setSortCriteria( this.sortField, this.sortOrder );
        }
        // Add the element to the sortControls, so that the list knows about it
        this.sortControls.push( element );
    }
    this.setSortCriteria = function( field, order )
    {
        /*
         * Sets the display's sort criteria (which field, and in what order) 
         * and triggers a display-update with those criteria.
         * 
         * field ............ (str, required) The field-name to sort display-list 
         *                    members by
         * order ............ (str, "up" or "down", required) The sort-order to 
         *                    sort display-list members by
         */
        this.sortField = field;
        this.sortOrder = order;

        // Clear all the sort-styles from all the sort-controls
        for( ci=0; ci<this.sortControls.length; ci++ )
        {
            sortControl = this.sortControls[ ci ];
            if ( sortControl.fieldName != this.sortField )
            { sortControl.className = 'sort-inactive'; }
            else
            { sortControl.className = 'sort-' + this.sortOrder; }
        }
        this.onJobsChanged();
    }
    // Static methods of the instance
    JobListView.setListItemTemplate = function( markup )
    {
        /*
         * Sets the class-level display-list member HTML template to use to 
         * display item data
         * 
         * markup ........... (str, required) The HTML markup to be used in 
         *                    generating list-item member displays. Uses 
         *                    data-field attributes to indicate what field-name 
         *                    from the jobs are displayed where.
         */
        JobListView._listItemTemplate = markup;
    }
    // Event-like methods of the instance
    this.onJobsChanged = function()
    {
        /*
         * Clears the current list-display of all members, and re-creates it with 
         * the current data. Called by JobManager.jobsChanged as part of their 
         * Observer-pattern relationship.
         */
        this.clearDisplay();
        this.displayItems();
        // Also, we need to update the various sort-controls:
        // Clear all the sort-styles from all the sort-controls
        for( ci=0; ci<this.sortControls.length; ci++ )
        {
            if ( this.sortField == this.sortControls[ ci ].sortField )
            { this.sortControls[ ci ].className = 'sort-' + this.sortControls[ ci ].sortOrder; }
            else
            { this.sortControls[ ci ].className = 'sort-inactive'; }
        }
    }
    // Processing that needs to happen before returning the instance, if any
    // - See if there are data-template and/or data-sort elements specified, 
    //   and if there are, set the template and sort-controls accordingly
    allChildren = _top.getElementsByTagName( '*' );
    templateNode = null;
    for( ci=0; ci<allChildren.length; ci++ )
    {
        checkNode = allChildren[ ci ];
        // Check for a data-template specification.
        isTemplate = checkNode.getAttribute( 'data-template' );
        if( ! templateNode && isTemplate == 'true' )
        {
            templateNode = checkNode;
            templateNode.removeAttribute( 'data-template' );
            break;
        }
        // Check for a data-sort specification
        isSort = checkNode.getAttribute( 'data-sort' );
        if ( isSort )
        {
            this.registerSortControl( checkNode, isSort );
            checkNode.removeAttribute( 'data-sort' );
        }
    }
    if( templateNode )
    { JobListView.setListItemTemplate( templateNode.outerHTML ); }
    // - Register this display with the JobManager
    jobManager.addDisplay( this );
    // Return the instance
    return this;
}

JobListView has a few additional members:

Properties
sortControls
A collection of sort-controls, created with the registerSortControl method.
Methods
registerSortControl( element, field )
Attaches events to the specified element to allow it to act as a sort-control, using the specified field as the sort-key.
setSortCriteria( field, order ) [scope]
Sets the sortField and sortOrder of the instance and triggers a refresh of the display (onJobsChanged)

The initialization/construction process also examines the _top element, starting with allChildren = _top.getElementsByTagName( '*' );, to locate any child elements with data-template and/or data-sort attributes. When they are found, each is handled as follows:

The first data-template element
...is identified as the templateNode for the instance, and its outerHTML (which would include the tag itself) is passed to JobListView.setListItemTemplate, replacing the default _listItemTemplate markup used to generate list-member displays.
(I find myself wondering how difficult it would be to implement multiple templates, so that each list-view generation would use the next template until it cycled back to the start of the set again. That's something for a later day, though... It doesn't make much sense for this POC effort.)
All data-sort elements
...are registered as a sort-control (registerSortControl) for the instance, using the value of the data-sort attribute as the field that the element will sort by/on.
It is, perhaps, worth noting that the processes for assigning the template markup and registering sort-controls will remove those data-* attributes from the final generated displays.

If no data-template or data-sort elements are found, the instance will still work. The templates and sort-controls could even be specified separately, since both of them have methods that can be used to those ends. I think, however, that it's more convenient, and that it allows a designer-level developer more and better control over the page markup to tweak, twiddle or frobnicate with as they see fit.

The getJobDisplay method might be worth taking a close look at — As part of it's generation of display-markup, it also looks for and sets up events on child elements with specific data-action attribute-values. Right now all it recognizes is remove, but it will link those items (the buttons) to JobManager.removeJob, storing the Job that the removal-action would apply to as a property of the button itself. This process could easily be extended to add, say, an Edit Job button or link to the template, though it would require some code on the job-UI side of things and on the design side to make that fully functional.

The JobContactList class

JobContactList attaches to the job-contact list-view, this part of the UI:

All Job Contacts
contact
All it does is generate a distinct, sorted list of all contacts across all Jobs in the JobManager. Its implementation is:

function JobContactList( parameters)
{
    /*
     * Provides a list-view of distinct job-contacts that responds to 
     * changes in the jobs in the UI.
     * 
     * parameters ...... (object, required) The job's data:
     *  + listElement .. (str|element) The element (or its ID) that will 
     *                   contain the list-items, which will be cleared of 
     *                   all child nodes and re-populated as needed/
     *                   arbitrarily.
     *  + top .......... (str|element) The element (or its ID) that wraps 
     *                   ALL of the UI managed by this object.
     */
    // Processing that needs to happen before definition, if any
    if ( typeof parameters.listElement == 'string' )
    { parameters.listElement = document.getElementById( 
        parameters.listElement ); }
    if ( typeof parameters.top == 'string' )
    { parameters.top = document.getElementById( parameters.top ); }
    // Instance properties:
    //  + Public properties
    this.sortField = 'contact'
    this.sortOrder = 'up'
    //  + Private properties
    var _jobManager = new JobManager();
    var _listElement = parameters.listElement;
    var _top = parameters.top;
    //  + Static properties
    JobContactList._listItemTemplate = '<div>Contact: <span data-field="contact">contact</span></div>';
    // Initialize as needed based on incoming arguments
    // Instance methods:
    //  + public methods
    this.clearDisplay = function()
    {
        /*
         * Removes all child element from the list-view
         */
        while( _listElement.lastChild )
        { _listElement.removeChild( _listElement.lastChild ); }
    }
    this.displayItems = function()
    {
        /*
         * Populates the listElement with zero-to-many job-displays using the 
         * jobManager's jobs and the current sort-field and -order.
         */
        // Get the jobs that we're concerned with showing
        displayJobs = jobManager.getSortedJobs( this.sortField, this.sortOrder );
        // Generate display markup for each job
        addedItems = {};
        for ( ji=0; ji<displayJobs.length; ji++ )
        {
            if ( typeof addedItems[ displayJobs[ ji ].contact ] == 'undefined' )
            {
                children = this.getJobDisplay( displayJobs[ ji ] );
                for( ci=0; ci<children.length; ci++ )
                { _listElement.appendChild( children[ ci ] ); }
                addedItems[ displayJobs[ ji ].contact ] = true;
            }
        }
    }
    this.getJobDisplay = function( job )
    {
        /*
         * Generates and returns a sequence of nodes based on the class-level 
         * HTML template, populated with the specified job's data, ready to be 
         * appended to the list-display element.
         * 
         * job .............. (Job or equivalent object, required) The job to 
         *                    generate the display for
         */
        // Generate a look-up of nodes by data-name
        dataChildren = {};
        for( dci=0; dci<Job.fieldNames.length; dci++ )
        { dataChildren[ Job.fieldNames[ dci ] ] = null; }
        // Create the wrapper-node that will be used to perform all the DOM 
        // examination and manipulation, and populate it with the template.
        wrapper = document.createElement( 'results' )
        wrapper.innerHTML = JobContactList._listItemTemplate;
        // Find all the data-display nodes and keep track of them.
        allChildren = wrapper.getElementsByTagName( '*' );
        for( ci=0; ci<allChildren.length; ci++ )
        {
            node = allChildren[ ci ];
            dataField = node.getAttribute( 'data-field' );
            if( dataField )
            {
                dataChildren[ dataField ] = node;
                // Remove the data-field attribute so as not to clutter the
                // final markup with it...
                node.removeAttribute( 'data-field' );
            }
            dataAction = node.getAttribute( 'data-action' );
            switch( dataAction )
            {
                case 'remove':
                    node.job = job;
                    node.onclick = function()
                        {
                            jobManager = new JobManager();
                            jobManager.removeJob( this.job );
                        }
            }
        }
        // Populate the nodes' innerHTML with the job's values.
        for( dci=0; dci<Job.fieldNames.length; dci++ )
        {
            if ( dataChildren[ Job.fieldNames[ dci ] ] )
            { dataChildren[ Job.fieldNames[ dci ] ].innerHTML = job[ Job.fieldNames[ dci ] ]; }
        }
        // Return the sequence of nodes
        return wrapper.childNodes;
    }
    // Static methods of the instance
    JobContactList.setListItemTemplate = function( markup )
    {
        /*
         * Sets the class-level display-list member HTML template to use to 
         * display item data
         * 
         * markup ........... (str, required) The HTML markup to be used in 
         *                    generating list-item member displays. Uses 
         *                    data-field attributes to indicate what field-name 
         *                    from the jobs are displayed where.
         */
        JobContactList._listItemTemplate = markup
    }
    // Event-like methods of the instance
    this.onJobsChanged = function()
    {
        /*
         * Clears the current list-display of all members, and re-creates it with 
         * the current data. Called by JobManager.jobsChanged as part of their 
         * Observer-pattern relationship.
         */
        this.clearDisplay();
        this.displayItems();
    }
    // Processing that needs to happen before returning the instance, if any
    // - See if there are data-template and/or data-sort elements specified, 
    //   and if there are, set the template and sort-controls accordingly
    allChildren = _top.getElementsByTagName( '*' );
    templateNode = null;
    for( ci=0; ci<allChildren.length; ci++ )
    {
        checkNode = allChildren[ ci ];
        // Check for a data-template specification.
        isTemplate = checkNode.getAttribute( 'data-template' );
        if( ! templateNode && isTemplate == 'true' )
        {
            templateNode = checkNode;
            templateNode.removeAttribute( 'data-template' );
            break;
        }
    }
    if( templateNode )
    { JobContactList.setListItemTemplate( templateNode.outerHTML ); }
    // - Register this display with the JobManager
    jobManager.addDisplay( this );
    // Return the instance
    return this;
}

JobContactList uses the same template-markup-acquisition process as JobListDisplay to auto-set the markup for its list-members, but doesn't look for sort-controls — this view doesn't support sorting. It could, but won't for now.

One item of potential interest/note: The displayItems method keeps track of which values (contacts) it has already added to the display-list by using the contact value as a property-name in a local object, and checking on each pass through the look whether that value is already accounted for. It's a simple, brute-force approach to making sure that no value appears in the display more than once, but it's not foolproof at this point.

The JobNameList class

JobNameList is almost identical to JobContactList (it displays job-names instead of -contacts). It relates to this part of the POC UI:

All Jobs
name
I've stripped the identical code out of the listing below for brevity:

function JobNameList( parameters )
{
    // Code is identical to JobContact list until this point

    //  + Public properties
    this.sortField = 'contact'
    this.sortOrder = 'up'

    // ...

    //  + Static properties
    JobNameList._listItemTemplate = '<div>Contact: <span data-field="name">name</span></div>';

    // ...

    this.displayItems = function()
    {
        /*
         * Populates the listElement with zero-to-many job-displays using the 
         * jobManager's jobs and the current sort-field and -order.
         */
        // Get the jobs that we're concerned with showing
        displayJobs = jobManager.getSortedJobs( this.sortField, this.sortOrder );
        // Generate display markup for each job
        addedItems = {};
        for ( ji=0; ji<displayJobs.length; ji++ )
        {
            if ( typeof addedItems[ displayJobs[ ji ].name ] == 'undefined' )
            {
                children = this.getJobDisplay( displayJobs[ ji ] );
                for( ci=0; ci<children.length; ci++ )
                { _listElement.appendChild( children[ ci ] ); }
                addedItems[ displayJobs[ ji ].name ] = true;
            }
        }
    }

    // ...

    // Static methods of the instance
    JobNameList.setListItemTemplate = function( markup )
    {
        /*
         * Sets the class-level display-list member HTML template to use to 
         * display item data
         * 
         * markup ........... (str, required) The HTML markup to be used in 
         *                    generating list-item member displays. Uses 
         *                    data-field attributes to indicate what field-name 
         *                    from the jobs are displayed where.
         */
        JobNameList._listItemTemplate = markup
    }
    // Event-like methods of the instance

    // ...

    // - Register this display with the JobManager
    jobManager.addDisplay( this );
    // Return the instance
    return this;
}

Given the similarities between this and JobContactList, I'm not sure that there is any meaningful commentary I can add about it...

That wraps up the three classes that share the common interface noted earlier. The one remaining class to detail before I'm done with this post is...

The JobCounter class

JobCounter is a much simpler class that the three I've shown so far, because its purpose is much simpler: To show the number of jobs currently in the JobManager's collection of Jobs. It lives in the header of the job-display list (the bold, red part below):

Current Jobs [job-count] ...

function JobCounter( element )
{
    /*
     * Provides an observer member that displays a count of jobs in the 
     * JobManager instance at a specified location
     * 
     * element ......... (element or str) The element, or the ID of the 
     *                   element, whose content will be updated with the 
     *                   current job-count.
     */
    // Processing that needs to happen before definition, if any
    if ( typeof element == 'string' )
    { element = document.getElementById( element ); }
    // Instance properties:
    //  + Private properties
    var _element = element;
    var _jobManager = new JobManager();
    // Event-like methods of the instance
    this.onJobsChanged = function()
    {
        /*
         * Clears the current list-display of all members, and re-creates 
         * it with the current data. Called by JobManager.jobsChanged as 
         * part of their Observer-pattern relationship.
         */
        jobCount = _jobManager.getSortedJobs().length;
        switch ( jobCount )
        {
            case 0:
                _element.innerHTML = 'No jobs';
                break;
            case 1:
                _element.innerHTML = '1 job';
                break;
            default:
                _element.innerHTML = jobCount + ' jobs';
                break;
        }
    }
    // Processing that needs to happen before returning the instance, if any
    // - Register this display with the JobManager
    jobManager.addDisplay( this );
    // Return the instance
    return this;
}

Its interface is correspondingly simple:

Properties
_element [private]
The page-element whose content will be updated with the current job-count when onJobsChanged is called.
_jobManager [private]
A reference to the JobManager that provides the collection of Jobs that the instance will display the count of.
Methods
onJobsChanged()
Counts the current jobs in _jobManager, generates formatted output, and replaces the contents of the _element with that output.

That wraps up all of the display-related classes, and allof the classes that are members in the Observer-pattern relationship between JobManager and display-classes at large. The only item remaining is the JobAdderForm class, which relates to the form for adding jobs to the JobManager's collection of Jobs. That, plus a functional demo of the whole UI is coming next...

No comments:

Post a Comment