Implementing HTML Tabbed Indexes

Let’s start with a more complete definition of a tabbed index. It’s a software widget that emulates an old-fashioned address book. The labels on the tabs can be letters of the alphabet, subject titles, or symbols. Each tab leads to a page that groups like elements—all reports that were written by Jon Udell, or that pertain to Microsoft, or that were due in August 1998.

Any implementation of a tabbed index in HTML exemplifies a pattern that I call repeat and vary. The element that repeats is a row of labeled links that I’ll call active tabs. The element that varies is the treatment applied to the current tab—that is, the single item that is not a link and whose nonlinked status signifies the current location in the data set.

In Figure 7.3, the current tabs are company and N. Together they signify that the page lists reports about products from companies whose names begin with N. Figure 7.5 shows what happens when you click the M tab. To signal the transition to the M state, the tabbed index undergoes two changes. The N tab gains link status and joins the rest of the active tabs. The M tab leaves the set of active tabs, loses link status, and takes on the big, bold style that signifies the current tab.

Tabbed index by company, after transition to the M page

Figure 7-5. Tabbed index by company, after transition to the M page

What makes this seem magical is a kind of figure-and-ground effect. The set of active tabs not involved in the transition forms a constant background for the N -> M exchange. Because the background doesn’t vary, the transition doesn’t appear to be a page swap. The tab row appears to be a single active widget, though it’s really an illusion created by a sequence of frames. As in motion-picture animation, the illusion depends on subtle change from frame to frame.

The Web abounds with examples of what I call false tabbed indexes. One type, shown in Figure 7.6, uses only a single row of tabs.

False tabbed index: only one tab row

Figure 7-6. False tabbed index: only one tab row

When you jump to a tabbed region (such as the M tab in Figure 7.6) you lose the tabbed index, as shown in Figure 7.7. You have to use the Up link (the upward-pointing arrow), or the browser’s Go Back button, to rewind before jumping to another section.[8]

False tabbed index: nowhere to go but up

Figure 7-7. False tabbed index: nowhere to go but up

Another kind of false tabbed index does reproduce the tab rows once per indexed chunk but fails to differentiate the current tab from the active tabs. The page shown in Figure 7.8 was produced by clicking the P tab.

False tabbed index: repetition without variation

Figure 7-8. False tabbed index: repetition without variation

In this context, every link on the tab row is a meaningful choice except the P tab. Because the tab row repeats but does not vary, it offers a meaningless P tab, which, when clicked, does nothing.

False tabbed indexes miss out on the best answers to the two basic navigational questions: “Where am I?” and “Where can I go from here?” A true tabbed index answers both effectively. The answer to “Where am I?” is “On the current tab.” The answer to “Where can I go from here?” is “To any active tab.”

It’s not surprising to see so many false tabbed indexes on the Web. Hand-coding 26 instances of a tab row, each slightly different from the next, is a daunting chore. True, you can build a page of these widgets and then reuse it. But how will that template page handle the situation shown in Figure 7.3: two correlated levels of context, the second of which entails a sparse, but evolving, subset of the 26 possible alphabetic tabs? It’s simply unfeasible to maintain these kinds of index pages by hand. This is a job for a script, so let’s go to work.

Gathering the Metadata

The values that will populate the tabbed-index pages are tucked into <meta> tags in the header of each docbase record. Let’s start with a function to retrieve a set of <meta> tag values from a docbase record. Since the records are stored in XML format, we could use Perl’s (or another) XML parser to read in a record, break it into its constituent parts, and pick out the metadata values. But there’s more than one way to do it, and in this case, that’s not the easiest way. It’s faster and simpler to use a Perl regular expression to match a pattern and isolate the values we need. Example 7.1 shows the getMetadata( ) method in Docbase::Docbase( ), which does that.

Example 7-1. Retrieving Metadata from a Docbase Record

sub getMetadata
  {
  my ($self, $doc) = @_;                   # $doc: /web/Docbase/docs/000014.htm

  my %record = ();                         # create empty hashtable

  open(F, $doc)                            # open the record
    or print "cannot open metadata $doc $!";  

  while (<F>)                              # for each line of the record
    {
    last if ( m#</head>#i );               # until end of HTML header
    if ( m#<meta name="$indexname" content="([^"]+)"#i )  # if found
      {
      $record{$indexname} = $1;            # add name/value pair to hashtable
      }
    }
  close F;
  return \%record;                         # return reference to hashtable
  }

Regular-expression aficionados will rightly point out that the pattern that recognizes <meta> tag names and values could be a lot more inclusive. For example, this code will miss <meta name="company"> for extra white space, <meta name=company> for lack of quotes, and <meta content="..." name="..."> for attribute order. Should the pattern generalize to accept some or all of these variations? There are two schools of thought. One holds that applications should emit strictly but accept liberally. Here, that would mean we’d observe a strict standard when writing <meta> tags but would allow some looseness when reading them. Why? Much of the resilience of the Internet flows from the fact that its communicating parts adhere to this philosophy.

The other school of thought holds that when you tolerate sloppiness, you create more of it. The example usually cited is the modern browser, bloated with so-called “bozo-correction” code that will accept anything that remotely resembles HTML, even when ill formed and ambiguous. XML parsers, in reaction to this stance, are very picky about what they accept. In this case the extra white space isn’t a problem, but the unquoted attribute will stop any XML parser dead in its tracks.

Both arguments have merit, and there’s no cut-and-dried answer. I recommend that you use an XML format even when, as in this case, you wind up relying on a simpler method of parsing. But you might also want to follow a stricter discipline than XML requires. For example, XML allows you to write the attributes of a tag in any order. But just because you can do something more than one way doesn’t mean that you should. People don’t write these docbase records, the Docbase::Input module does, and it will write them the same way every time. Assume that, and you can match <meta> tags with a very simple regular expression. Assume otherwise, and that regular expression gets a lot more complicated. You could account for different attribute orderings, but why bother? Life’s too short to waste time making tools more flexible than they need to be. The endgame, after all, is to use those tools to get working groupware applications into people’s hands.

You call getMetadata( ) with a docbase record’s fully qualified pathname. It returns a reference to a hashtable whose keys are the <meta> tag names, and values are drawn from the <meta> tag’s content attribute. If you pass getMetadata( ) the pathname of the record shown in Figure 7.1, that hashtable will look like this:

$metadata = 
  {
  'company' => 'Netscape',
  'product' => 'Calendar Server 4.0',
  'analyst' => 'Ben Smith',
  }

Looping over the records of the docbase produces a collection of these per-record hashtables:

opendir(DIR, $docdir) or print "cannot open docdir $docdir $!
";
my @docs = grep ( /htm/, readdir(DIR) );
my $master = {};
foreach my $doc (@docs)
  { $master->{$doc} = $self->getMetadata("$docdir/$doc"); }

The resulting structure, in $master, looks like this:

$master = 
  {
  '000127.htm', 
    {company =>'Netscape', product => 'Calendar Server',},
  '000128.htm', 
    {company =>'Microsoft', product => 'Internet Explorer 5.0',},
  };

Building the Core Index Structure

Now we want to transform the $master hashtable into a richer structure. For each index (that is, each docbase header field), this new structure will bind a (possibly sparse) set of tab values to the subset of records governed by each tab. The index defines the primary ordering within each subset.

The method that accomplishes this transformation, buildIndexStructures( ), appears in Example 7.2. It’s a complex transformation, which does a lot of different things all at once, so we’ll explicate it step by step.

Example 7-2. The buildIndexStructures Method

sub buildIndexStructures
  {
  my $self = shift;              
  my $docdir = $self->{docdir};                # docbase path
  my $tabHoHoLoL = $self->{tabHoHoLoL};        # master data structure
  my @indexed_fields =                         # get list of indexed fields
    @{$self->{indexed_fields}}; 

  opendir(DIR, $docdir) or print "cannot open docdir $docdir $!
";
  my @docs = grep ( /htm/, readdir(DIR) );
  foreach my $doc (@docs)                      # for each docbase record
    {
    my $record =                               # extract metadata
      $self->getMetadata("$docdir/$doc");  
    foreach my $index (keys %$record)          # for each metadata (index) name
      {
      my $tabstruct = $tabHoHoLoL->{$index};   # tabstruct for this index
                                                         
      my $tab =                                # compute tab for this value
        &{$self->{tab_functions}->{$index}}($record->{$index}); 

      if ( ! defined $tabstruct->{$tab} )      # if new tab for this index's tabstruct
        { $tabstruct->{$tab} = [];  }          # then initialize a listref for it

      $doc =~ s/[^d-]+//g;                   # strip pathname to bare docnum

      my $rec =                                # build new entry for this record
        [$doc,"$record->{$index}",$self->_getNonKeyValues($index,$record)];

      my $LoL = $tabstruct->{$tab};            # get list-of-lists for this tab

      push ( @{$LoL}, $rec );                  # add entry to list
      }
    }

  foreach my $index (keys %$tabHoHoLoL)        # for each index
    {
    my $sort_specs = $self->{sort_specs};      # hashtable of sort specs
    my $sortdir = $sort_specs->{$index};       # the sort spec for this index
    my $tabstruct = $tabHoHoLoL->{$index};         # the tabstruct for this index
    foreach my $tab (keys %$tabstruct)         # for each tab
      {
      my $LoL = $tabstruct->{$tab};            # get list-of-lists
      my @temp = sort                          # sort
        {   
        if ( $sortdir eq 'ascending' )
          { return $a->[1] cmp $b->[1]  || $b->[0] cmp $a->[0] }
        else
          { return $b->[1] cmp $a->[1]  || $b->[0] cmp $a->[0] }
        } @$LoL;
      $tabstruct->{$tab} = @temp;             # replace LoL with sorted result
      }

    my @sorted_tabs = ($sortdir eq 'ascending')  # sort tabs according to spec
      ?         sort keys %$tabstruct
      : reverse sort keys %$tabstruct;
    $self->{tabsortHoL}->{$index} = @sorted_tabs;
    }
  }

This routine iterates over the complete docbase and builds two structures. In the main structure, shown in Example 7.3, each index has a sparse set of tabs, each of which contains a set of ordered records.

Example 7-3. Example of Ordered Records

$tabHoHoLoL = 
  {
  'company' => 
    {
    'N', [
        ['000127','Netscape','Calendar Server 4.0','Ben Smith'],
        ['000123','Netscape','Directory Server 4.0', 'Jon Udell']
        ],
    'M', [
        ['000128','Microsoft','Internet Explorer 5.0','Jon Udell'],
        ['000124','Microsoft','IIS 5.0','Ed DeJesus'], 
        ],
    },
  'product' => {...},
  'analyst' => {...},
  }
};

In the second structure, shown in Example 7.4, each index has an ordered set of tabs.

Example 7-4. Example of Ordered Tabs

$tabsortHoL =
  {
  'company' => ['A', 'C', 'M', 'N'],
  'product' => ['O', 'E', 'I', 'C'],
  'analyst' => [...],
  }

In Perlspeak the structure in Example 7.3 is an HoHoLoL—that is, a hash-of-hashes-ofs-of-lists. Working from the outside in, the enclosing hashtable’s keys are the index names, and its values are hashtables. Each of the nested hashtable’s keys are tab values extracted from the set of values governed by the controlling index. Each of the nested hashtable’s values are lists of the records governed by the controlling tab. Finally, each of these records is a list of metadata values.

We’ve already seen how to extract metadata from docbase records. What buildIndexStructures( ) does with each of those records, though, takes a bit of explaining. Let’s start with the line that computes a tab for the current index of the current record:

$tab = &{$self->{tab_functions}->{$index}}($record->{$index}); # compute tab

We’ll start in the middle of this expression and work outward. $self->{tab_functions} is a reference to a hashtable that looks like this:

{
'company' , $tabFnFirstChar,
'product' , $tabFnFirstChar,
'analyst' , $tabFnAll,
'duedate' , $tabFnFirstSevenChars,
};

When $index is company, $self->{tab_functions}->{$index} evaluates to $tabFnFirstChar—which is a reference to an anonymous function defined in the following code.

my $tabFnFirstChar = 
  sub { my ($tab) = @_; return substr($tab,0,1) };

So the expression &{$self->{tab_functions}->{$index}} is equivalent to &{$tabFnFirstChar}—in other words, a function call. And since $record->{$index} extracts the current metadata value—say, Netscape—the effective function call is &{$tabFnFirstChar}('Netscape').

Note how $tab_functions expands the idea of a tab to be any function computable on an index value. In our example the first character is appropriate for company and product indexes, the whole name for analyst, and the first seven characters (year and month) for duedate. Other functions will make sense for other indexes.

This approach means that a tabbed-index system can evolve along with its underlying data set. Consider the duedate index. After a year, each tab row will be 12 items long. But after three years, rows of 36 monthly tabs will have become unwieldy. Swapping tabFnFirstSevenChars for tabFnFirstFourChars will reduce the tab row to a manageable three yearly tabs.

Accumulating Tab Sets for Each Index

For each tab, we ask whether a corresponding key exists in the current index’s interior hashtable:

if ( ! defined $tabstruct->{$tab} )

For example, is there an N key in the hashtable belonging to the company key in the master hashtable? If not, we create that key and initialize its value to be a reference to an empty list:

$tabstruct->{$tab} = []

Next, we build the interior list that holds the metadata for this record, relative to the current index. That list, which will display on the tabbed-index page, is made up of three parts:

The record number

This is the bare name of the record’s file minus its extension.

The primary sort key

This is the value for the current index’s slot in the current metadata record. If the index is company, it might be Netscape.

The remaining values

These are the other values that, along with the primary key, will form the link that appears on the tabbed-index page.

The _getNonKeyValues( ) method, which returns a list of values that excludes the primary key, relies on a list, stored in Docbase::Indexer’s $self->{indexed_fields}, which serves two purposes. First, it enumerates which indexes to build, as we’ll see when we run the indexing script. Second, it defines the order in which fields appear on tabbed-index pages. Example 7.5 shows how that second use of the indexed-fields list comes into play.

Example 7-5. Completing a Tabbed-Index Record Using an Ordered Enumeration

sub _getNonKeyValues
  {
  my ($self,$special_key, $record) = @_;
  my @return_list = ();
  foreach my $key (@{$self->{indexed_fields}})
    {
    if ( $key ne $special_key )
      {
      push ( @return_list, $record->{$key} );
      }
    }
  return @return_list;
  }

Why can’t this routine just iterate over the keys of the $record hashtable, using foreach my $key (keys %$record){...}? When you enumerate the keys of a hashtable, they won’t come out in any particular order unless you control the enumeration with a list.

Finally, we form a reference to a list made up of the record number, the primary key, and the remaining values. And we add that list reference to the current tab’s list-of-lists (LoL, for short).

Sorting the Records and Tabs

Once the structure shown in Example 7.3 has been built, we traverse it and sort each of the LoLs. Although the buildIndexStructures routine shown in Example 7.2 inlines the sorting code for reasons of efficiency, an equivalent and more readable version might look like this:

@temp = sort sort_fn @$LoL;

sub sort_fn = { return $a->[1] cmp $b->[1]; }

Here it’s easier to see what’s happening. When you interpose a user-defined function between Perl’s sort operator and the list you want sorted, your function gets called once per comparison. The values it should compare show up automatically as $a and $b. This fragment compares the first element of each list—that is, the company name for the company index, the analyst name for the analyst index, and so on. For a descending sort, you’d swap the locations of $a and $b.

To sort on multiple keys, you can tie multiple comparisons together with the || operator. (I found this recipe in O’Reilly’s indispensable Perl Cookbook by Tom Christiansen and Nathan Torkington.) In our case, when the primary key matches, we want to do a reverse sort by record number. That way, records that share a primary key will appear newest first within the region defined by the primary key. Since the record number is the zeroth element of each list, here’s how to do that:

return $a->[1] cmp $b->[1] || $b->[0] cmp $a->[0];

Example 7.2 uses this construct twice—once for an ascending sort by primary key and once for a descending sort. Why not segregate this sorting code into a separate function? You can, but the inline version shown in Example 7.2 is almost twice as fast. Function call overhead is fairly expensive in Perl. Normally that’s not a huge concern, but in tight inner loops like this one, it can really pay off to inline a function.

Finally, we also sort the keys of each index’s HoLoL to create per-index ordered lists of tabs. We save these lists in the instance variable $self->{tabsortHoL} and use them to generate tab rows.

Both sorting operations refer to the instance variable $self->{sort_specs}, a hashtable that defines the sort order for each index.

$sort_specs = {
'company' , 'ascending',
'product' , 'ascending',
'analyst' , 'ascending',
'duedate' , 'descending',
}

When $index is company, for example, $sort_specs->{$index} specifies an ascending sort. We apply that ordering first to the LoLs attached to each of the tabs in each index’s hashtable, then to the tabs themselves.

To SQL or not to SQL?

In the Docbase system, records appear in a primary order governed by the current index (say, company) and a secondary order that is reverse chronological. It’s as if we said:

select * from docbase order by company, age desc

Each record’s age is encoded in its filename, by virtue of the fact that these are numbered sequentially. As a practical matter, the age of a docbase record is almost always one of the orderings that you’d like an indexing system to provide. Here it’s always present as the default secondary ordering.

What about a tertiary ordering? Suppose you want to present the docbase equivalent of:

select * from from docbase order by company, product, age desc

The Docbase system doesn’t do a tertiary sort. And while it could be made to perform fancier orderings of data, I’m not sure that would make sense. At that point you’re taking Perl outside its comfort zone. A scripting language that’s tuned for rapid development of data-wrangling logic—such as Perl or, if you prefer, Python, REXX, or another alternative—works best within its sweet spot. At the fringes of that zone, you’ll reap diminishing returns.

The question is not whether Perl can emulate SQL but rather at what point the cost of that effort outweighs the benefit. When you use Perl within its comfort zone, the benefits, vis à vis an alternative SQL-oriented data-management discipline, include the following.

Low overhead

SQL’s overhead includes the initial cost of acquiring and installing an engine and the ongoing administrative effort required to use it. There are lots of situations that require SQL (see Chapter 15) and others where object-style data management makes the most sense (see Chapter 10). But when you don’t need the declarative querying, and transactional controls that SQL offers, you may not need the overhead of SQL.

Flexible schema

A docbase record is just a bag of fields. The schema that governs a docbase is just a convention shared by the input template, its processing script, and the indexing module. This means that any record can be a variant record that adds special fields for special purposes.

For example, the Virtual Press Room docbase sometimes needed to tag certain press releases that announced products nominated for awards at trade shows. During these periods, I added an extra input field to the form—for example, SpringComdex98. There was no need to propagate this mutation across the entire docbase. Clusters of variant records governed by these special-purpose tags can co-exist peacefully with normal records. This flexibility makes data prototyping quick and easy.

“Good-enough” indexing

The Docbase system aims for a modest goal. It’s a way to handle sets of semistructured records like those found in your email or newsgroup folders but with customized, user-defined fields. Your mailreader probably doesn’t support a query like:

select author, date from folder where date > #1999-04-15# and author =
     'Ben Smith'

But then, it doesn’t really need to. We manage our inboxes pretty effectively just by using the canned views they support—sort by author, by date, and so on. These modes are good enough for our information-rich local message stores, and they’re good enough for a lot of simple docbases too.

Constructing the Tabbed-Index Pages

Now let’s traverse the index structure and write the tabbed-index pages. The buildTabbedIndexes( ) method, shown in Example 7.6, does most of that work.

Example 7-6. The buildTabbedIndexes Method

sub buildTabbedIndexes
  {
  my ($self) = @_;
  my $tabstyle = $self->{tabstyle};         # single-page or multipage?
  my $navstyle = $self->{navstyle};         # dynamic or static?
  my $idxdir = $self->{idxdir};             # absolute path of index pages
  my $idxref = $self->{idxref};             # web-relative path of index pages
  my $sort_specs = $self->{sort_specs};     # sort specs for each field
  my @indexed_fields =                      # list of indexed fields
    @{$self->{indexed_fields}};  
  my $tabHoHoLoL = $self->{tabHoHoLoL};     # main index structure

  my $path = ( $navstyle eq 'dynamic' )     # dynamic or static delivery?
    ? $idxref                               # Web-server-relative path
    : "."    ;                              # file-system-relative path
      
  foreach my $current_index (@indexed_fields)
    {          
    my $major_tab_row = "<center>";

    foreach my $index (@indexed_fields)     # for each index (e.g. company)
      {
      my @sorted_tabs = @{$self->{tabsortHoL}->{$index}};

      my $firsttab =                        # remember first tab
        $tc->escape($sorted_tabs[0]); 

      if ($index eq $current_index)         # current tab
        { 
        $major_tab_row .= 
          " <strong><font size=+2>$index</font></strong> "; 
        }
      else                                  # active tab
        {
        if ($tabstyle eq 'single')          # single-tab major row
          {
          $major_tab_row .= 
            " <a href="$path/$index.htm">$index</a> "; 
          }
        else                                # multitab major row
          {
          $major_tab_row .= 
            " <a href="$path/$index-$firsttab.htm">$index</a> ";
          }
        }
      }

    $major_tab_row .= "</center><blockquote>";

    if ($tabstyle eq 'single')              # if single-page mode, create the page
      { 
      my $fname = "$idxdir/$current_index.htm";
      open(FS,">$fname") or die "cannot open current index $fname $!"; 
      print FS $major_tab_row;
      } 


    my @sorted_tabs = @{$self->{tabsortHoL}->{$current_index}};

    foreach my $current_tab (@sorted_tabs)  # minor tab row and records
      {
      my $minor_tab_row = $self->_makeMinorTabRow($current_tab,$current_index,
           @sorted_tabs); 
      my $tab_records = $self->_enumerateRecords($current_tab,$current_index); 

      if ($tabstyle eq 'multi')             # make a new page
        {
        my $fname = "$idxdir/$current_index-$current_tab.htm";
        open(FM,">$fname") or die "cannot open current index $fname $!";
        print FM $major_tab_row; 
        print FM $minor_tab_row;
        print FM $tab_records;
        close FM;
        }
      else                                  # add to current page
        {
        print FS $minor_tab_row;
        print FS $tab_records;
        }
      }

    if ($tabstyle eq 'single')              # pad bottom of page
      { 
      print FS "<pre>";
      for (0..50) { print FS "
"; }
      print FS "</pre>";
      close FS ;
      }
    } 
  }

Single-page and multipage tabbed indexes

The buildTabbedIndexes( ) method starts by looking at the instance variable $self->{tabstyle}. It controls whether the second-level tab rows, and their associated records, will be grouped onto a single page or spread across multiple pages. The example we saw in Figure 7.3 illustrates the multipage style. In the company index, the A company records are listed in a file called company-A.htm, the B records in company-B.htm, and so on. Alternatively, in the single-page style shown in Figure 7.9, all the company records appear in the file company.htm.

The single-page tabbed-index style

Figure 7-9. The single-page tabbed-index style

Why, in the single-page case, do we pad the bottom of the page with white space? This ensures that every clickable link will yield a visible result—namely, that the row corresponding to that link will snap to the top of the browser’s window. Without the padding, a tab row that’s less than a screenful distant from the bottom of the page won’t react when its tab is clicked. This is another one of the tiny details that add up to create consistent and predictable behavior. Every time you create a link, you create an expectation that some behavior will result from clicking it. A design that frustrates that expectation—for example, the meaningless P tab in Figure 7.8—subtly undermines the user’s confidence in the whole system. A user interface is really an illusion, one that depends heavily on consistency of behavior. Do everything you can to perfect that illusion.

Why use the single-page style at all? A new docbase tends to be sparsely populated. The buildIndexStructures( ) routine won’t create an empty tab, but it will create tabs that have just one or two entries. These look pretty lonely on pages of their own. Moreover, when the data set is very small, there’s no reason not to pack it all onto a single page so the user can scan everything at once.

Early in the life cycle of a docbase, when there are not many records, it makes sense to display all of them on a single index page. That way the user can scan the whole data set, as well as navigate by chunks. When the index page gets too big for quick downloading and comfortable scanning, you can switch to the multipage method. We saw earlier how to reduce the size of individual tab values as the number of values grows. Here’s another way in which a docbase’s navigational system can evolve as the docbase grows.

Emitting the tabbed-index pages

buildTabbedIndexes( ) loops over all the indexes defined in the instance variable $self->{indexed_fields}. It starts by building the major tab row for the current index. To do that, we capture the first element of the sorted list of tab values as $firsttab. Why? In the multipage style, the names of the index pages include tab values—for example, company-N.htm. Suppose that a tab row has analyst as its current tab and company as an active tab. We can’t blindly link that active tab to the name company-A.htm, because in a sparse data set, that page might not exist. Instead we want to link each active tab to the first available page in the indicated set. The first element of the sorted tabs gives us the ability to form that page’s name. Note that we call escape( ) on the tab’s value. The reason is that we’re creating an HTML link address, so we need to ensure that Jon Udell will be written as Jon%20Udell.

Armed with $firsttab, we can emit the major tab row for the current index. If the name of the index we’re building matches the current tab, then we apply font styling but don’t create an HTML hyperlink. Otherwise, we do create a hyperlink, whose address is a filename that varies according to the mode. In single-page mode, it’s just the bare index name, for example, company.htm. In multipage mode, it’s a combination of that name and the first tab in the set; for example, company-A.htm.

Next we loop through the ordered set of tabs. For each, we create a minor tab row using the helper routine _makeMinorTabRow( ), shown in Example 7.7.

Example 7-7. The _makeMinorTabRow Method

sub _makeMinorTabRow
  {
  my ($self,$current_tab,$current_index,$listref) = @_;
  my @indexed_fields = $self->{indexed_fields};
  my $target = $tc->escape($current_tab);                # escape the target name
  my $tabIndex =                                         # begin HTML fragment
    "
<p><center><a name="$target">";
  my $path = ( $self->{navstyle} eq 'dynamic' )          # if dynamic version
    ? $self->{idxref}                                    # URLs start with this
    : "."    ;                                           # else with this

  foreach my $tab (@$listref)
    {
    my $subtab = $tc->escape($tab);                      # escape the tab name
    if ($current_tab ne $tab)                            # active tab
      {
      if ($self->{tabstyle} eq 'single')                 # single-page style
        { $tabIndex .= " <a href="#$subtab">$tab</a> ";}
      else                                               # multipage style
        { $tabIndex .= "&nbsp;<a href="$path/$current_index-$subtab.htm">
        $tab</a>&nbsp;";}
      }
    else                                                 # current tab
      {  $tabIndex .= "&nbsp;<strong><font size=+2>$tab</font></strong>&nbsp;";  }
    }
  return $tabIndex . "</center><br>
";
  }

As with the major tab row, the minor tab row adapts to the prevailing style—either single-page or multipage. It also adapts to the prevailing mode of the system—that is, dynamic or static.

The records governed by each minor tab row are written by _enumerateRecords( ), shown in Example 7.8.

Example 7-8. The _enumerateRecords Method

sub _enumerateRecords
  {
  my ($self,$current_tab,$current_index) = @_;
  my $app = $self->{app};
  my $cgi = $self->{db}->{docbase_cgi_relative};
  my $tabHoHoLoL = $self->{tabHoHoLoL};
  my $return_val = '';
  foreach my $rowref (@{$tabHoHoLoL->{$current_index}->{$current_tab}})
    {
    my $docnum   = $rowref->[0];                       # extract record number
    my $path = ( $self->{navstyle} eq 'dynamic' )      # form mode-appropriate URL
      ? "$cgi/doc-view.pl?app=$app&doc=$docnum&index=$current_index"
      : "../seq/f-$docnum-$current_index.htm" ;
    my @row = @$rowref;
    shift @row;                                        # remove record number
    push(@{$self->{idxHoL}->{$current_index}}, $docnum); # save record number
    $return_val .=                                     # construct HTML fragment
      "<br><a href="$path">" . join(', ',@row) . "</a>" ;
    }
  return $return_val;                                    
  }

The _enumerateRecords( ) method walks the list of records for the current index and current tab. It wraps link syntax around each record’s fields, excluding the record number stored in the first field of each record. Here too, the link address varies according to the mode of the system—dynamic or static. It also builds a new data structure in Docbase::Indexer’s instance variable $self->{idxHoL}. This structure is a hash-of-lists that stores, for each index, an ordered list of record numbers, as shown in Example 7.9.

Example 7-9. Per-index Lists of Ordered Record Numbers

$self->{idxHoL} = {
  {'company', ('000127','000104','000113')},
  {'product', ('000104','000127','000113')},
}

These ordered lists are the basis of the sequential controls that we’ll add later. For now, let’s focus on just the tabbed-index controls. We want each of the links written by _enumerateRecords( ) to invoke a page that, like the one shown in Figure 7.1, includes a tabbed-index selector. And we want that selector to work in a context-preserving way. So, for example, if the link goes to a record whose product field is Calendar Server 4.0, then selecting company on the tabbed-index dropdown list should lead to a tabbed-index page that meets two criteria. On its major tab row, company should be the current tab. On its minor tab row, N (for Netscape) should be the current tab.

What addresses should _enumerateRecords( ) assign to these links? That depends on whether we’re using the static or the dynamic approach. In the former case, it’s the address of an HTML page; we’ll see later how to build those pages. In the latter case, it’s a CGI call that looks like this:

/cgi-bin/Docbase/doc-view.pl?app=ProductAnalysis&index=company&doc=000123

The script doc-view.pl, using the services of the Docbase::Navigate module, combines the static HTML page containing the raw docbase record with a dynamically generated tabbed-index selector. Let’s see how that’s done.

Dynamically Generating the Context-Sensitive Tabbed-Index Selector

We’ll start with a template for the tabbed-index selector. Here’s the basic skeleton:

<form name=Index>
<select name=idx onChange="gotoTabbedIndex(idx.options[idx.selectedIndex].value);">
<option value=choose>choose tabbed index</option>
<OPTION VALUE=company>company tabs</option>
<OPTION VALUE=product>product tabs</option>
<OPTION VALUE=duedate>duedate tabs</option>
<OPTION VALUE=analyst>analyst tabs</option>
</select>
</form>

You can add any desired HTML flesh to this skeleton. For example, in Figure 7.1 the tabbed-index selector appears in a cell of a table.

To streamline the selector, we wire it to the JavaScript onChange event. That way, we don’t need to include another widget—for example, a Go button to perform the switch to the selected index page.

The onChange event needs a JavaScript handler, which we’re calling gotoTabbed-Index( ), so we’ll need to include it in the template as well:

function gotoTabbedIndex(index)
  {
  if (index != 'choose')
    {
    url = "DOCBASE_WEB/DOCBASE_APP/idxs/" + index + ".htm";
    location = url;
    }
  }

The doc-view.pl script, its companion script doc-nav.pl, and their supporting module Docbase::Navigate, mainly concern themselves with sequential navigation. In the next section, we’ll see in more detail how these pieces work. Briefly, Docbase::Navigate::fillNavigationTemplate( ) reads an HTML/JavaScript template, which includes the parts we’ve seen here to support the tabbed-index control, plus other parts that support the sequential controls. It interpolates values into the template. Then, it interpolates that result into the raw docbase record to produce a generated HTML page that includes the navigational controls.

Where do the controls go on the page? The docbase template we saw back in Chapter 6 (Example 6.1) includes a placeholder (the HTML comment <!-- navcontrols -->) for this dynamically generated element.

For the tabbed-index selector, fillNavigationTemplate( ) makes the substitutions shown in Table 7.1.

Table 7-1. Navigation Template: Substitutions for the Tabbed-Index Selector

Marker

Description

DOCBASE_WEB

The HTTP root for the target index page; e.g., Docbase

DOCBASE_APP

The Docbase subdirectory for the target index page; e.g., ProductAnalysis

OPTION VALUE=...

A new <OPTION> tag whose <VALUE> attribute names the appropriate index page; e.g., company.htm (single-page mode) or company-N.htm (multipage mode)

In multipage mode, the appropriate target for the jump is the page that preserves the context of the current record. To figure that out, Docbase::Docbase provides the makeContextTabs( ) method, shown in Example 7.10.

Example 7-10. The makeContextTabs( ) Method

sub makeContextTabs
  {
  my ($self,$doc) = @_;
  my $metadata =                                          # extract metadata
    $self->getMetadata("$self->{docbase_web_absolute}/$self->{app}/docs/
         $doc.htm");

  my $tab_fns = $self->getDefaultValue('tab_functions'),  # acquire tab functions

  my $tabs = {};

  foreach my $key (keys %$metadata)
    {
    my $idx_fn = $tab_fns->{$key};                        # get tab function
    $tabs->{$key} =                                       # apply tab function
      $tc->escape(&{$idx_fn}($metadata->{$key}));
    }

  return $tabs;
  }

makeContextTabs( ) uses some of the machinery that we’ve already seen. It calls getMetadata( ) to read the current record’s header into a hashtable. Then it grabs the tab_functions hashtable that maps indexed fields to the functions that convert their values into tab values. Then it applies those functions to produce another hashtable that maps from fields to the corresponding tab values for the current record, like this:

$tabs = {
'company' => 'company-N',
'analyst' => 'Ben%20Smith',
'product' => 'product-C',
}

With this information in hand, the substitutions applied to the template’s <OPTION> tags can yield the correct results:

if ($self->{tabstyle} eq 'multi')
  { s/<OPTION VALUE=(w+)/<option value=$1-$tabs->{$1}/; }
else
  { s/<OPTION VALUE=(w+)/<option value=$1/; }


[8] While this book was in production, I noticed that this false tabbed index had become a true one that repeats, and varies, correctly!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
100.28.2.72