Thursday, August 24, 2006

SntT -- A prettier view

Not everything needs to be Ajaxian. A simple bit of JS can make a standard "Display using HTML" on a $$ViewTemplate look and work a whole bunch better. No hand-coded "Treat as" views, no fancy background requests need apply. Just a passthru <div> tag around the embedded view, this script, and init() in the onload event of the $$ViewTemplate.

By way of explanation for the long listing to follow:

First, I NEVER comment code quite this way in production. The comments here are for the benefit of people who may not be very familiar with JavaScript, and might have trouble following the "story" otherwise. If you want to use the code, do everybody a big favour and delete the comments. If you are at all familiar with JS, you'll probably find it easier to follow the code without the comments anyway. (They do get in the way, don't they?)

Second, there is a lot of explicit use of getElementsByTagName(). I'd never let something like this hit production with all of those wasted characters floating around in there. I use a simple little function instead:

function $tn(tn,el){el=el?el:document;return el.getElementsByTagName(tn);}

So, instead of:

var viewTable = viewPanel.getElementsByTagName("table")[0]

I would write:

var viewTable = $tn("table",viewPanel)

That is a half-truth at best. I'd probably write "var vw=$tn("table",vP)". Or at least have an obfuscator do it for me. I'm not "into" obfuscation, as a rule, but in an interpreted language where the user has to download the source code, sometimes over a bad POTS modem connection, killing off characters is the best way to save the plot.

The following little bit of JavaScript takes an ordinary, Domino-created, "Display using HTML" view and transforms it into something that looks and acts a little more "applicationy". It preserves the selection margin and any clickable headers. Flat? Single category? LOTS of categories? Response docs? Response-to-responses? Action bar? It's all good. Try it out, play with it. You may like it, you may not. And no, Peter, it doesn't mess with the correct functionality when you open and close categories -- the clicked category scrolls to the top if the page is long enough to be scrollable. Sorry about the formatting -- it's gonna be a little bit on the wide side. You'll want to copy and paste this into something that has syntax highlighting (like a JS script library in Designer) -- trying to make it really pretty here makes it too wide for the screen. Even if you have a fifty-incher.

function prettyView(){
  var debugPos = "";

/************************************************
This function adds a whole-row mouseover and click
event to a Domino "Display using HTML" view.
************************************************/

  var panel = document.getElementById('viewPanel');
  //assumes you have wrapped the view in a DIV with an ID of "viewPanel"

  /***************************************************
  Getting to the view may take some work. You KNOW the
  table lies inside your DIV, but Domino may just have
  closed your passthru DIV without asking.
  No, it SHOULDN'T happen. Yes, it DOES.
  ***************************************************/
  
  //Try the easy way first
  var viewTable = panel.getElementsByTagName("table")[0];
  
  //If that didn't work...
  if (!viewTable) {
    //It might be because there were No Documents Found...
    if (panel.getElementsByTagName("h2").length) {
      return;
      }
    //...or maybe Domino ate your DIV for lunch.
    else {    
      panel = panel.parentNode;
      if (panel.tagName) {
        viewTable = panel.getElementsByTagName("table")[0];
        }
      //Of course, the No Documents Found rule could still be in effect...
      if (!viewTable && panel.getElementsByTagName("h2").length) {
        return;
        }
      }
    }

  //First, fix the situation where a collapsed categorized view
  //is all squished over to the left-hand side of the browser
  viewTable.width="100%";
  
  //Then get the collection of rows.
  var rows = viewTable.getElementsByTagName("tr");
  if (rows.length) {
    //We don't want to mess with the header row if it's there.
    //It may contain column resort hinkies.
    //Domino 6 and up renders the header cells as TH elements.
    var startRow = (rows[0].getElementsByTagName("th").length)? 1 : 0;
    for (var i=startRow;i<rows.length;i++){
      //Now, make sure the row is in the view table.
      //Response documents may be rendered in nested tables.
      var grandparent = rows[i].parentNode.parentNode;
      if (grandparent === viewTable) {
        //the first "parent" is an imaginary tbody element
        //so it's row->tbody->table to get to viewTable
        var href = "";
        var titleText = "";
        //Add the mouseover highlight to each row...
          rows[i].onmouseover = new Function("this.bgColor='#ffff99'");
        //...and return to the original color on mouseout.
        //This maintains any alternate colors set in the View Properties.
        rows[i].onmouseout = new Function("this.bgColor='" + rows[i].bgColor + "'");
        //Now get all of the cells in the row...
        var cells = rows[i].getElementsByTagName('td');
        for (var j=0; j<cells.length; j++) {
          try{
            //There is going to be an error at the end of this TRY
            //related to garbage collection of the objects we create here.
            //It is unavoidable.
            //The best we can do is CATCH the error and ignore it.
            
            //We don't want to change the behaviour of cells in the
            //selection margin.
            if (!(cells[j].getElementsByTagName("input").length>0)) {
              //We need to find links to create the whole-row click.
              var links = cells[j].getElementsByTagName("a");
              if (links.length) {
                var count = 0;
                var link = links[0];
                //Not all A tags represent links. We will pass over
                //any named anchors (A tags with a NAME and no HREF).
                while (!link.href || link.href == "") {
                  link = links[++count];
                  }
                href = link.href;
                //We also need to know what's inside the link.
                var children = link.childNodes;
                var testNode = children[0];
                if (testNode && (typeof testNode == "object")){
                  if (testNode.tagName && testNode.tagName.toLowerCase() == "img"){
                    //In this case, it's a picture -- probably a twistie
                    titleText = testNode.alt;
                    }
                  else {
                    //Otherwise, there's got to be text in there somewhere.
                    //It may be nested in FONT tags, and there may be empty
                    //DOM nodes.
                    while (testNode.childNodes.length) {
                      testNode = testNode.childNodes[0];
                      }
                    while (testNode && testNode.nodeType!=3 && testNode.nodeValue!=""){
                      testNode = testNode.nextSibling;
                      }
                    //After all of that, we may not have any text...
                    if (testNode) {
                      //...but if we do, we replace the original link with plain text.
                      var swapNode = link.parentNode;
                      swapNode.replaceChild(testNode,link);
                      
                      /************************************************************
                      NOTE: Those last two lines help the view LOOK a lot prettier,
                      but they also affect accessibility. Keyboard-only users will
                      not be able to tab from link to link. If accessibility is
                      important, comment those two lines out.
                      ************************************************************/
                      
                      titleText = "Click to open " + testNode.nodeValue;

                      /************************************************************
                      For some unknown and unholy reason, Domino renders response
                      documents in nested tables in one column of the main table.
                      Not only does it make this sort of code harder (whine, grumble),
                      but it also means that response docs will shove the main document
                      content over to the right. This will fix that by removing
                      the final cells in the main table's response row and adding
                      their width to the response cell. The rest of the table can then
                      collapse back to normal size.
                      ************************************************************/
                      if (rows[i].getElementsByTagName("table").length) {
                        //This is a response doc, and we are stuck in a nested table.
                        //In order to keep the responses from pushing everything over,
                        //we need to find the outer cell containing the table...
                        var parentCell = cells[j].parentNode;
                        while (!parentCell || parentCell.nodeType != 1 || parentCell.tagName.toLowerCase() != "td") {
                          parentCell = parentCell.parentNode;
                          }
                        //...and work on getting rid of the following cells
                        var killCell = parentCell.nextSibling;
                        var removedCellCount = 0;
                        while (killCell) {
                          //Before removing any cells, we need to find out how wide they were.
                          var oldColspan = killCell.colSpan;
                          rows[i].removeChild(killCell);
                          killCell = parentCell.nextSibling;
                          removedCellCount += oldColspan;
                          }
                        //Now we add the width we removed to the response cell...
                        parentCell.colSpan = parentCell.colSpan + removedCellCount;
                        //...add the onclick event ...
                        parentCell.onclick = new Function("getLink('" + href + "')");
                        //... and the mouseover text.
                        parentCell.title = titleText;
                        //Finally, we change the cursor to tell the user they're
                        //mousing over a link.
                        parentCell.style.cursor = "pointer";
                        }
                      }
                    }
                  }
                }
              if (href != "") {
                //If, after all of that, we have a link location to use,
                //we add an onclick to the cell to take the user to the link...
                cells[j].onclick = new Function("getLink('" + href + "')");
                //... and add the mouseover text.
                cells[j].title = titleText;
                //Finally, we change the cursor to tell the user they're
                //mousing over a link.
                cells[j].style.cursor="pointer";
                }
              }
            }
          catch(e){
            alert(e.message);
            //ignore -- it's because of nested tables on response rows
            }
          }
        }
      }
    }
  }


function getLink(){
  var el=arguments[0];
  if (typeof el == "string") {
    window.location.href = el;
    }
  }

function init(){
prettyView();
}

I try to avoid calling any function in the onload that isn't called "init()" -- that means I can change the function names in JavaScript with a search-and-replace and never have to worry about changing the body onload. The init() function calls the prettyView() function, and the prettyView() function adds an onclick call to the getLink() function. You'll need all three in your JS script library or JS Header.

You can improve the appearance of the view by adding the following CSS:

TABLE {
font-size: 1em;
border-collapse: collapse;
}
#viewPanel TR, #viewPanel TD, #viewPanel TH  {
border-bottom: solid black 1px;
}
#viewPanel TABLE TABLE TR, #viewPanel TABLE TABLE TD, #viewPanel TABLE TABLE TH {
border-bottom: none;
}

You need to set the border-bottom of the TD and TH in order to get any lines at all. The lines won't extend all the way across all of the cells, though, unless you also set the border-bottom for the TR. The border-collapse: collapse; makes sure that both sets of lines look like one. The selectors with TABLE TABLE in them make sure that the response document nested tables don't get multiple bottom borders.

UPDATE:Sometimes I hate this posting of snippets stuff SO much. There was a small problem with what I posted yesterday, in that it came from an earlier version of the original file. (A quick look at getLink() should tell you that it was excerpted from a bigger mess -- it's designed to handle links based on table row ids as well as href values.) The actual, honest-to-goodness production code lives in a template on a server (or group of servers) to which I haven't had access in a couple years, so I had to rely on what I had in text and *.js files here. I gave it a quick test before posting, but then I tested it again, and, well....

The changes live in the little loop where I go looking for the parent cell of response documents. The original code would break if something other than 10pt Default Whatever Plain is selected as the font for the responses-only column. That has been changed so the code will continue upwards to find the containing cell. I've also made the link replacement code two lines instead of one to solve a node resolution problem introduced when looking for the outer cell. For some reason, doing this:

someNode.replaceChild(testNode,link);
was a problem, but doing this instead:
var theSameNode = someNode;
theSameNode.replaceChild(testNode,link);
fixes it. As the code above implies, it's the same node. Not just the same HTML element, but the identical object. The identity check, (theSameNode === someNode), will return true. Yet the two-line version works in every browser I could test, and the one-line version fails in almost all of them. Only Opera, usually the worst browser for complex JS because it swaps engine components when you change its spoofing settings, actually got it right all of the time. Mozilla and IE would bail if the link was on a responses column with a font setting.

I've thrown in the only fix I could think of for the view title alignment problem noted in the comments. Oh, and the <h2>No Documents Found</h2> has been fixed, too (that was in the working original). NOW I know that multiple, seemingly-identical bits of snippetalia on a drive probably means that one is right and most of the rest are just-in-case backups that should have been nuked. Oh, well.

Technorati:

10 comments:

ben dubuc said...

Hey Stan. I wanted to have a look at this, because I hate doing views as HTML, but I have a funny error message saying that viewTable has no properties.

While looking at the super Firefox debugger, I noticed that the viewTable object was not set. I then tried the following:

var tmpobj=viewTable = document.getElementById('viewPanel');
var viewTable = tmpobj.getElementsByTagName("table")[0];

the tmpobj gets set but the viewTable object doesn't.

Yes, I have a div around the view (otherwise the first line wouldn't work.

Any clues?

Thanks (wdwpux ... Mmmm ... not bad)

Adi said...

Mine worked (very proud about it too...). Are you sure your embedded view is not perhaps also marked as pass-thru HTML, together with the div tags?

Rob McDonagh said...

@Ben: Maybe you made one of the same mistakes I made: first, I didn't give the div tag an ID of viewPanel, and then I pointed to a view with no documents (ha!). Both produce the result you're seeing. Just fyi...

Tom Roberts said...

@Ben:

I ran in to the same problem for a bit. In my case, Domino had added a closing div tag after my starting passthru html div tag with the viewPanel id. The reason the extra div tag was there was for centering of the view title. The end result was the Domino generated closing div was closing my viewPanel div prior to the view's html, and the closing div I added was closing the centering div Domino had created.

Stan Rogers said...

When in doubt, Ben, use the Source. There's got to be something getting between the div and the table (as Tom noted), and looking at the raw HTML is often the only way to sort these things out. I have never used the "Display title" option, prefering to use @ViewTitle is computed text, so I hadn't noticed the problem. A possible fix is:

if (!viewTable) {
newTable = viewPanel.nextSibling;
while (newTable && newTable.tagName.toLowerCase() != "table") {
newTable = newTable.nextSibling;
}
}

I haven't tested that, but if the viewPanel div is getting closed without a new div being applied, then it should work. Let me know how it goes.

Stan Rogers said...

OOOPS. Ahem -- you might want to assign newTable to the viewTable variable when you're done with that:

if (!viewTable) {
newTable = viewPanel.nextSibling;
while (newTable && newTable.tagName.toLowerCase() != "table") {
newTable = newTable.nextSibling;
}
if (newTable) {
viewTable = newTable;
}
}

If it bombs after all of that, then I'd have to see the HTML that's giving you the problem.

Stan Rogers said...

@Rob -- the No Documents FOund behaviour is what pointed me to the conclusion that the file I had spent all of that time commenting up and making HTML-friendly was, in fact, a pre-last-best effort. It works perfectly well on the perfect test view, but won't survive the "edge cases". I've found the last version I worked on, and swapped in the differences that make it work. Particular problems were font settings on the columns and the no docs thing.

Oh, for anyone interested, the problem that the catch() is catching happens immediately after the parentCell.style.cursor = "pointer" line. There are no further references to the variable, and the Mozilla exception seems to hint that the garbage collector is having a problem (it's not a normal "you screwed up error message).

Ed Maloney said...

A little off-topic (and beating the dead "we need upated templates" horse), but look at all the effort it takes to get a decent looking web view out of Domino. Shouldn't there just be a canned feature for this in Designer by now?
Anyway, thanks for posting the code samples!

Stan Rogers said...

There is a "canned feature" already -- the Java applet. Works good, lasts long time. Oh, and users can elect not to run it.

ed maloney said...

When the view applet is working, it is very impressive. The last time that I used this in an application my phone immediately started ringing - not working on OS/X, users not able to figure out the applet security warnings, and some erratic behaviors that were difficult to reproduce. I don't know if the R7.x version has improved, but this would be a great feature if it worked better.