Making webdrawer's results sortable and searchable

The out-of-the-box design of webdrawer does not include common data table functionality.  For instance, the column headers & labels can't be clicked.  They also don't indicate if that column is being sorted. 

 
 
2017-09-30_21-56-32.png
 

 

The ubiquitous dataTable library could be imported into webdrawer and then applied to the results list.  If done, the results list will appear as shown below.

2017-10-02_16-03-39.png

With this users can now:

  • Change how many items are in the view
  • Search all rows and columns for a given value
  • Click any column header and sort the items by that column

Even cooler, if the user types into the search box then all non-matching rows will be hidden.

2017-10-02_16-06-21.png

 

Although Webdrawer's out-of-the-box footer works perfectly well, I feel the dataTable library includes a better pagination.  Also, by increasing the page size and enabling pagination here, more advanced features can be added (such as column formulas, signature lines, save as excel workbook).  Once enabled the footer would appear like shown below.

 
2017-09-30_22-00-59.png
 

It's super easy to incorporate this into your own implementation.  Definitely test and tune your environment, as I'm just showing the broad brushstrokes.  Contact me for more details about implementation or more advanced features.  Otherwise here are the instructions.


First I opened the "Views/Shared" resultsList class file in Visual Studio.  I located the table element which will contain all of the records.  I then added an id attribute with "records" as the value.

2017-09-30_20-00-37.png

Now that my table is named, I went into my initialize method (previously used to setup the map on the page) and added a invocation of the jquery dataTable plugin.  I provided it the name and a configuration object that enabled pagination.  

2017-09-30_20-02-22.png

I then imported the dataTable jquery plugin via a public CDN, like so:

<link href="https://cdn.datatables.net/1.10.16/css/jquery.dataTables.min.css" type="text/css" rel="stylesheet" />

That's it.  Three lines of code.  Crazy, isn't it?  :)

Adding an image carousel to Webdrawer results

In an earlier post I showed how we could add a map to the search results page of webdrawer.  It's also possible to add an image carousel.  With a carousel the user can cycle through the images included in the search results. 

Notice the carousel in the top right corner of the search results

Notice the carousel in the top right corner of the search results

Adding in this feature was super easy.  I've included the steps below.


To start, I opened the results list webdrawer template file (/Views/Shares/resultsList.cshtml) in Visual Studio.  I then found where I had placed my map and then adjust it so that I had a carousel above it.  The carousel is already defined within the bootstrap framework, something heavily used throughout the webdrawer product.  

<td id="infoColumn">
	<table height=100%>
		<tr height=50%>
			<td>
				<h2>Images</h2>
				<div id="imageCarousel" class="carousel slide">
					<!-- Carousel items -->
					<div class="carousel-inner">
					</div>
					<!-- Carousel nav -->
					<a class="carousel-control left" href="#imageCarousel" data-slide="prev">&lsaquo;</a>
					<a class="carousel-control right" href="#imageCarousel" data-slide="next">&rsaquo;</a>
				</div>
			</td>
		</tr>
		<tr height=10><td>&nbsp;</td></tr>
		<tr height=50%>
			<td><h2>Geolocations</h2><br><div id="mapDiv"></div></td>
		</tr>
	</table>
</td>

Below this I already had a bit of logic looping through the results so that markers can be added to the map, so I just needed to update that logic.  I need to add in a conditional output of an "addCarousel" function call for each supported extension.  This ends up just being 4 new lines of code, but I've included the lot below.

if (Model.Results.Count > 0)
{
	<script type="text/javascript">
 
	@foreach (TrimObject record in Model.Results)
	{
		var gpsloc = record.GetPropertyOrFieldString("RecordGpsLocation");
		if ( !String.IsNullOrWhiteSpace(gpsloc) )
		{
			@Html.Raw("addMarker('" + record.GetPropertyOrFieldString("RecordNumber"+ "','" + gpsloc + "');");
		}
		
		var extension = record.GetPropertyOrFieldString("RecordExtension").ToLower();
		if ( !String.IsNullOrWhiteSpace(extension) && (extension.Equals("png")||extension.Equals("jpg")||extension.Equals("bmp")) ) {
			@Html.Raw("addCarousel('" + record.GetPropertyOrFieldString("Uri"+ "','" + record.GetPropertyOrFieldString("RecordExtension"+ "','" + gpsloc + "');");
		}
	}
	</script>
}

Lastly, I went back towards the top of my file and defined a new function named "addCarousel".  This function should result in a new image being pushed into the carousel.  I also need to create a variable to track how many items are in the carousel (this will be required to use indicators).  

var carouselItems = 0;
function addCarousel(uri, ltlg) {
	if ( carouselItems == 0 ) {
		$('<div class="item active"><img class="d-block img-fluid" src="/o1/Record/'+uri+'/File/Document"></div>').appendTo('.carousel-inner');
	} else {
		$('<div class="item"><img class="d-block img-fluid" src="/o1/Record/'+uri+'/File/Document"></div>').appendTo('.carousel-inner');
	}
	carouselItems++;
}

You may want to import jquery v3.  Webdrawer is using v1.what.ever.it.is.  I did this right above the definition of the above script by using the tag below.

<script src="https://code.jquery.com/jquery-3.1.1.slim.min.js" integrity="sha384-A7FZj7v+d/sdmMqp/nOQwliLvUsJfDHW+k9Omg/a/EheAdgtzNs3hpfag6Ed950n" crossorigin="anonymous"></script>

So with like 30 lines of code we were able to add a cool image carousel to webdrawer.  Nifty.

Re-thinking maps in webdrawer

Webdrawer is one possible implementation of a public records portal.  It does its' job and it does it well.  However, it has the distinct disadvantage of having already been built.  I feel like I'm forcing a square map down a round webdrawer (or something like that).  And it doesn't have to be "my way or the highway" with Content Manager! 

First I need to list what I didn't like about my Webdrawer Maps:

  • My map only showed the current page of search results
  • Moving to the next page of search results requires a complete reload of the page 
  • Using the quick search results in a complete reload of the page 
  • Reloading of the page results in loss of other facilities in the map
  • Scrolling around on my map didn't show me other facilities

So I decided to play with something newish: the ServiceAPI.

Here's what I ended up with as a prototype:

2017-09-28_7-52-26.png

Features of this page include:

  1. One page -- the design uses one page that "wires together" the ServiceAPI with Google Maps via jquery
  2. User interface events -- buttons, links, and controls provide interactive experience 
  3. Facilities data -- the list of facilities projected onto the map as markers.  
  4. Facility Info Cards -- clicking a map marker or the "center" link provides the user with a pop-up information window, a distance circle from the marker, and an additional set of search links.  Clicking a search link run a records search within the circle.  

In this post I'll break down the current design and explain how (and why) it was created.


One page

The layout I want is straightforward: I want a map, a search area, and a list of search results.  If I do everything else via jquery then I'll have the responsive design I want.  I've color coded my regions so that you can get an idea what I'm thinking.

Visual styling is intentionally rudimentary since this is not a "real project".

Visual styling is intentionally rudimentary since this is not a "real project".

 
 
  • Yellow -- Search controls
  • Purple -- Loading/busy message
  • Blue -- Search results
  • Pink -- Map coordinates
  • Gray -- Banner

Now I can model this out within a single HTML file, like shown below...

<!DOCTYPE html>
<html>
 
<head>
    <meta name="viewport" content="initial-scale=1.0, user=scalable=no" />
 
    <link href="css/map.css" type="text/css" rel="stylesheet" />
    <!-- Access Google Maps API -->
    <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?key=donteventhinkaboutit"></script>
    <script type="text/javascript" src="scripts/jquery-3.2.1.js"></script>
    <script type="text/javascript" src="scripts/map.js"></script>
    <script type="text/javascript" src="scripts/cm.js"></script>
    <script type="text/javascript" src="scripts/ui.js"></script>
 
    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <script type="text/javascript">
 
        $(document).ready(function () {
            initializePage();
        });
    </script>
</head>
 
<body>
    <table id="container">
        <tr>
            <td colspan="2" height=30 style="background-color:gray">
                Content Manager Ramble -- ServiceAPI integration with Google Maps
            </td>
        </tr>
        <tr>
            <td width="70%">
                <div id="mapDiv"></div>
                <div id="coordsDiv"></div>
            </td>
            <td>
                <table style="height:100%width100%">
                    <tr height="100">
                        <td align=center valign="middle">
                            <div id="searchDiv">
                                <span>Title/Keyword&nbsp;</span><input id="keywordInput" type="text" /><br><Br><input type=submit id='searchButton' value=Search /><input type="button" id="loadVisibleFacilities" value="Visible in Map" />
                            </div>
                            <div id="errorDiv"></div>
                        </td>
                    </tr>
                    <tr>
                        <td align=center rowspan=2>
                            <div id="loadingDiv"><span class="label label-danger">Danger...Loading!</span></div>
                            <div id="resultsDiv"></div>
                            <div id="notFoundDiv"></div>
                        </td>
                    </tr>
                </table>
            </td>
        </tr>
    </table>
</body>
</html>

That's it!  This HTML file lays out the various elements of my user interface in the form of "<div>"'s. 


User Interface Events

In my previous post I showed how to plot facilities onto a map as the webdrawer page loads.  I want to do something along the same lines here.  Though here I'm going to use the jquery library (not the google maps API) to add some event listeners to the page.

I can think of several events I want to trigger upon:

  1. Load -- once the page is loaded, go fetch all the facilities, mark them on the page, and show the list in the search results page
  2. Click marker -- zoom/center onto the marker and show an information window for the related facility
  3. Click search -- filter the list of facilities to just those included in the search results
  4. Click visible facilities -- filter the list of facilities to just those included in the bounds of the map

If you look back at the HTML code at the top of this post you'll see the javascript code that kicks this whole process off.

 
<script type="text/javascript" src="scripts/jquery-3.2.1.js"></script>
<script type="text/javascript" src="scripts/map.js"></script>
<script type="text/javascript" src="scripts/cm.js"></script>
<script type="text/javascript" src="scripts/ui.js"></script>
<script type="text/javascript">
 
    $(document).ready(function () {
        initializeUI();
    });
</script>

The code above imports a few javascript files I've created and tells the browser to execute "initializeUI" once the document is ready (loaded).  I've included my initialize function below, so that you can see how I've implemented each.

 
function initializeUI() {
    // hide the loading message (since nothing is loading right now)
    var $loading = $('#loadingDiv').hide();
    // when ajax started: tell user via message; when it stops: hide that message
    $(document)
        .ajaxStart(function () {
            $loading.show();
        })
        .ajaxStop(function () {
            $loading.hide();
        });
    // fetch facilities with default search
    loadFacilities();
    // clicking search should apply current search query and fetch
    var searchButton = $('#searchButton').click(function () {
        loadFacilities();
    });
    // clicking visible facilities should filter result list to map bounds
    var loadVisibleFacilities = $('#loadVisibleFacilities').click(function () {
        for (i = 0; i < facilities.length; i++) {
            var mm = facilities[i].mapmarker;
            facilities[i].showInResults = false;
            if (mm != null) {
                var markerpos = mm.getPosition();
                if (map.getBounds().contains(markerpos)) {
                    facilities[i].showInResults = true;
                }
            }
        }
        refreshFacilityList();
    });
}

Facility Data

Now that my design is a one-page HTML file, I need to figure out how to get my data. 

Webdrawer is still a viable option.  I could craft a search string and pass it along into the standard "Record?q=mysearchstringgoeshere" URL.  I could even tack on "&format=json" to get my results without the UI.

 
Webdrawer results page with UI

Webdrawer results page with UI

 
Webdrawer results page without UI

Webdrawer results page without UI

There are a few things I don't like about this approach:

  • It exposes meta-data fields unnecessarily
  • It lets any person see how to manipulate the URL so that different data might be returned

So instead of continuing to add onto webdrawer, I moved it into a separate site called "o1" (option 1).  I then created a new site named "o2" (option 2), which is a duplicate copy of the CM ServiceAPI.  Then I adjusted the hptrim.config file in o2 so that I have routes for "Map", "Facilities", and "Facility".

2017-09-28_9-37-19.png

This means I don't have to worry about someone altering the URL and exfiltrating records I don't want them to have. 

Altering the URL does not expose the document I created today

Altering the URL does not expose the document I created today

It also means my user interface can retrieve data from the "/Facilities?format=json" URL

2017-09-28_9-39-24.png

Great, so now I've got a single web-page partitioned into a few user interface elements.  I've also got a Json data service exposing just a list of facililties, and only the fields I need for my user interface.  Time to wire it up!


Ajax loading

Now that I have my one-page user interface "wired-up" to do things, I need to tell it how to actually do those things.  First things first.... I need to load my facilities.  To accomplish that I'll craft a query string (based on the current state of the user interface), disable the search controls, and initiate an ajax call.

function loadFacilities() {
    qs = generateQuery();
    if (isEmpty(qs)) {
        enableSearch();
    } else {
        disableSearch();
        ajaxActive = true;
        $.ajax({
            url: 'http://wg1/o2/Facilities',
            type: 'GET',
            data: { q: qs, format: 'json', pagesize: 5000, start: 0 },
            contentType: 'text/html; charset=utf-8',
            success: function (returnData) {
                updateResults(returnData);
            },
            error: function (err) {
                displayError(err.statusText);
            },
        }).always(function (jq, ts) {
            if (ts != "success") {
                displayError(jq.statusText);
            }
            enableSearch();
        });
    }
}

In the above code I fire-off a request to my new "o2/Facilities" data service (provided by the ServiceAPI).  When I get a successful response I "updateResults".  When I get an error I "displayError".  Either way, I re-enable the search interface once completed.

Within this implementation I want to place all facilities on the map, but only selectively display them in the search results list.  When a user searches, I will toggle the display in the list (or on the map) based upon the later results.  By taking this approach, I reduce the constant loading/unloading of facility information and improve the overall user experience.

for (i = 0; i < facilities.length; i++) {
    facilities[i].showInResults = false;
}
        // import list of facilities matching search criteria
$.each(obj.Results, function (key) {
    var rec = obj.Results[key];
    var found = false;
    for (i = 0; i < facilities.length; i++) {
        if (facilities[i].uri == rec.Uri) {
            facilities[i].showInResults = true;
            found = true;
            break;
        }
    }
    if (!found) {
        var facility = { "uri": rec.Uri, "number": rec.RecordNumber.Value, "title": rec.RecordTitle.Value, "latlng": rec.RecordGpsLocation.Value, "mapmarker"null, showInResults: true };
        facilities.push(facility);
        addMapMarker(facility);
    }
});

 

That's it!  Now if the user clicks "search", the ajax call will get back a list of facilties matching the new search query.  The function above will "hide" all facilities from the search results and "show" only those in the results.  I like this approach because it means I can manipulate the map for items not included in the search results.  I'll need that capability when I start adding map layers.


That's it!  Now I've got a fully functional single HTML page that can interact with both the ServiceAPI and Google Maps.  More importantly, it doesn't expose any data out via webdrawer.  Lastly, I'm now prepared to implement my "search within this circle" concept via the map.