UserDB: log timestamps to second granularity
[interchange.git] / code / UserTag / report_table.tag
1 # Copyright 2002-2007 Interchange Development Group and others
2
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 2 of the License, or
6 # (at your option) any later version.  See the LICENSE file for details.
7
8 # $Id: report_table.tag,v 1.5 2007-03-30 23:40:57 pajamian Exp $
9
10 UserTag report-table addAttr
11 UserTag report-table Documentation <<EOD
12
13 By Chris Wenham of Synesmedia, Inc. - www.synesmedia.com
14 This software is distributed under the terms of the GNU Public License.
15 Version 1.2, November 20, 2003.
16
17 Generate an HTML table based on the results of a query, with bells and
18 whistles. Can do horizontal (colspan) and vertical (rowspan) subheaders,
19 apply any Interchange filter or widget to any column, add a CSS class to
20 any column, link cell contents (and add parameters to the link based on
21 any column in the query results), add virtual columns based on internal
22 variables (such as the line number), and skip rows based on an array of
23 toggles you specify.
24 Good for making quick tables, sophisticated reports, and easy forms.
25
26 Synopsis and minimum syntax
27
28         <table>
29         [report-table
30                 query="SELECT * FROM addresses"
31                 columns="address city state zip"
32         ]
33         </table>
34
35 Or something fancier:
36
37         <form action="[process]">
38         <table>
39         [report-table
40                 query="SELECT * FROM addresses"
41                 columns="state city address sales"
42                 column_defs="{
43                         state => {
44                                 header => 'vert',
45                         },
46
47                         city => {
48                                 header => 'vert',
49                         }
50
51                         zip => {
52                                 title  => "Zip code:",
53                                 header => 'horiz',
54                         }
55
56                         address => {
57                                 width  => '40%',
58                                 widget => 'text',
59                                 widget_cols => '20'
60                         }
61
62                         sales => {
63                                 prefix => '$',
64                         }
65                 }"
66         ]
67         <tr>
68           <td colspan="4" align="right">
69             <input type="hidden" name="rows" value="[scratch report_table_linecount]" $Vend::Xtrailer>
70             <input type="submit" value="Save addresses" $Vend::Xtrailer>
71           </td>
72         </tr>
73         </table>
74         </form>
75
76 This last example could give you something like this:
77
78  +-------------------------------------------------------+
79  | state | city      | address                | sales    |
80  |-------+-----------+-----------------------------------|
81  |  NY   | Levittown |          Zip code: 11756          |
82  |       |           |-----------------------------------|
83  |       |           | [123 Return Lane_____] | $240.12  |
84  |       |           | [321 Raspberry Lane__] | $43.52   |
85  |       |-----------+-----------------------------------|
86  |       | Bellmore  |          Zip code: 11710          |
87  |       |           |-----------------------------------|
88  |       |           | [23 Merrick Road_____] | $354.06  |
89  |       |           | [43 Bellmore Ave_____] | $11.34   |
90  |-------+-----------+-----------------------------------|
91  |  PA   | Anytown   |          Zip code: 23456          |
92  |       |           |-----------------------------------|
93  |       |           | [63 Some Street______] | $771.35  |
94  |-------------------------------------------------------|
95  |                                    [ Save addresses ] |
96  +-------------------------------------------------------+
97
98
99 The columns to include in the report are passed in the "columns"
100 tag parameter.
101
102 Column definitions are defined in a perl hash of hash references.
103 The tag will display only the columns you specify, and in that order.
104 Pagination is not supported, but you can easily construct the logic for
105 that outside of the report-table tag, and then use OFFSET and LIMIT in
106 the query.
107
108 Vertical headers (state and city in this example) are always sorted
109 to the left of the table, but they can be nested to any level. The tag
110 does not support vertical headers within the scope of a horizontal
111 header.
112
113 Horizontal headers can also be nested to any level. You might want to
114 pass a "class" value in the column definition so you can style them
115 later and make it easier to tell them apart.
116 NOTE: Columns used for horizontal headers should *not* be included in
117 the "columns" parameter of the report-table tag. Defining them in
118 column_defs is sufficient.
119
120 Advanced column definitions
121
122 The following parameters are supported for the column definitions.
123
124         title => 'Column Header'
125         The tag will default to the database column name, but you
126         can override it with a title. All titles are put in <th>
127         tags at the top of each column, or in the case of
128         horizontal subheaders they're put just before the value
129         (eg: "Zip code: 11756" from above)
130
131         header => 'vert'
132         Indicates that this column is a header, and whether it's
133         vertical ('vert') or horizontal ('horiz').
134         Headers are generated every time the value in that column
135         changes between rows. Let's say that the following are
136         the rows returned by the query:
137
138         NY,Levittown,11756,123 Return Lane
139         NY,Levittown,11756,321 Raspberry Lane
140         NY,Bellmore,11710,23 Merrick Road
141
142         If city was a header, then it would spit out "Levittown"
143         first, then two rows later spit out "Bellmore".
144
145         NOTE: To make headers work properly, you must sort by those
146         columns in your query, or you may get redundant headers.
147
148         prefix => '$', postfix => '%'
149         Something to insert just before and after the value. Will
150         appear after the title in a horizontal header, and outside any
151         widget or link.
152
153         filter => 'digits_dot'
154         Any Interchange filter. Will be applied to the cell value
155         before it's put into any link or widget.
156
157         widget => 'date'
158         Any Interchange form widget. The widget will be passed the
159         contents of the cell as the default value. The name of the
160         form widget will be the column name plus the line number.
161         Eg: "address_1", "address_2", and so-on.
162         You can pass any addtional parameter supported by the [widget]
163         tag (such as rows and cols) by prefixing them with "widget_".
164         EG: "widget_cols => '30'".
165
166         Any column can be a widget, even vertical and horizontal
167         headers.
168
169         class => 'currency'
170         Will give you <td class="currency"> for each cell in that
171         column.
172
173         align => 'right', valign => 'top'
174         Sets the alignment of each cell in the column. Vertical headers
175         are valign="top" by default, but this can override.
176
177         width => '50%'
178         Set the column width.
179
180         link      => 'show_customer'
181         link_parm => 'id'
182         link_key  => 'cust_id'
183         Link a cell's contents using Interchange's [page] tag, and
184         optionally passing a parameter based on any column in the
185         query results. So let's say "cust_id" is a column returned in
186         the database query, but not actually displayed in the result.
187         The cells in your customer column could be linked to the
188         "show_customer" page, passing the value of "cust_id" in a
189         parameter named "id". Like this:
190         http://www.store.com/cgi-bin/catalog/show_customer?id=523
191
192         NOTE: You can't use a link and a widget at the same time. If you
193         set the 'link' parameter, any widget in the same column def will
194         be ignored.
195
196         empty => '&nbsp;'
197         What to use instead if the cell is empty for that row. For
198         tables with borders set, you might want to use a nonbreaking
199         space (&nbsp;), or 0.00 for currency columns, or whatever.
200         NOTE: The tag can't tell the difference between an empty cell
201         and a NULL cell.
202
203         dynamic => 'linecount'
204         Indicates a column that does not draw its data from the query
205         results, but from an internal value. Most of these aren't
206         terribly useful, but 'linecount' is good for adding line numbers.
207         Dynamic values can be used with links, widgets and filters, but
208         they can't be used as subheaders. Available dynamic values are:
209
210                 realrow
211                 The absolute current row from the query results. Is not
212                 affected by the row_toggle parameter (described later).
213                 Begins at zero.
214
215                 rowcount
216                 The current row, including any used by horizontal
217                 subheaders.
218                 Begins at zero.
219
220                 linecount
221                 The current data line. Does not include lines used by
222                 horizontal subheaders.
223                 Begins at 1.
224
225                 parity
226                 1 if we're on an odd numbered line, 0 if we're on an
227                 even numbered line.
228
229 Other parameters
230
231         row_toggle="1,1,1,1,1,1,0,1,1,0,1"
232         This is a comma separated list of toggles ('1' or '0') that
233         can be used to make the report skip individual rows in the
234         results. The number of toggles must either equal the number
235         of results from the query, or the remainder will be skipped.
236         Eg: passing row_toggle="1,1,0,1,1,1" and a query that returns
237         six rows will give you a five-row report, where the third
238         row from the results had been skipped. If the query returns
239         more than six rows, then the remainder will be skipped.
240
241         (Ideally, what you should probably do is just modify your
242         query so it doesn't return those rows anyway, but this feature
243         was added for a special application.)
244
245         row_hidden_id="address_id"
246         The name of a column in the query results to use in a
247         type="hidden" form element. This is for forms that need to pass
248         the database key's value for each row, and is added just before
249         the first data cell, like this:
250
251         <tr><input type="hidden" name="id_1" value="523"/><td...
252
253         The number appended after "id_" in the name is the linecount,
254         and will match the number appended to the name of any other
255         widgets on the same row.
256
257         title_horiz="0"
258         If you want the value of horizontal subheaders to stand on
259         their own (without a title), then set title_horiz="0".
260         Otherwise the tag will use the database name or title of
261         the column.
262
263         reset_horiz="0"
264         By default, the scope of a horizontal header does not cross
265         the scope of a vertical header. It looks confusing and
266         doesn't follow the typical way subheaders are used. So when
267         a vertical header goes out of scope, it resets all the
268         horizontal headers so they begin anew with the next row.
269         Example: Some zip codes cross city boundaries, so the
270         "Levittown" vertical header could end, but the next address
271         might still be in the "11756" zip code. By default, the
272         report table will simply run the "Zip code: 11756" header
273         again before the next row.
274         If you don't want it to do this, meaning you want the scope of
275         horizontal headers to cross the scope of vertical headers,
276         then pass reset_horiz="0".
277
278         display_colheaders="0"
279         When set to zero, don't bother to display the column headers.
280
281         no_results="<tr><td>Woah dude, nothing to see!</td></tr>"
282         Override the default message when there are no results from
283         the query.
284
285
286 HTML output
287
288 Outputs XHMTL compliant markup*.
289
290 This tag will not generate the <table> tags in the final HTML because
291 it's trivial to add those yourself, and it was designed to be used in
292 cases where the table might not be "finished" even when the report-table
293 tag was (such as when you're using it to create a form).
294
295 The column headers row will be written with <tr class="headers">.
296
297 Every odd-numbered row will be written with <tr class="odd">.
298
299 The total number of columns it will use will always be the same as what
300 you pass in the "columns" parameter*. Even when the query returns no
301 results, it will still return one complete row with an apropriate
302 colspan (unless overridden by the no_results parameter).
303
304 * Except if you use a widget that doesn't output XHTML.
305
306 ** Except if you were naughty and listed a column that is later defined
307 as a horizontal header, then it will get stripped out. You shouldn't list
308 horizontal headers in the colums="" parameter. Simply defining them in
309 column_defs is sufficient.
310
311
312 Side-effects
313
314 The following temporary scratch variables are set prior to tag completion.
315
316         [scratch report_table_rowcount]
317         The total number of rows created by the tag. This includes rows
318         used up by horizontal subheaders, and the column header row.
319
320         [scratch report_table_linecount]
321         Total number of data rows returned by the tag, NOT including rows
322         used by horizontal subheaders or the column headers. Useful if
323         you're using widgets and your mv_nextpage needs to know how many
324         values there are.
325
326         [scratch report_table_colspan]
327         Total number of columns it used.
328
329
330 Tips and Tricks
331
332 To get a blank column:
333
334         columns="city state zip x customer"
335         column_defs="{
336                 x => {
337                         title      => '&nbsp;',
338                         empty_cell => '&nbsp;'
339                 }
340         }"
341
342
343 EOD
344 UserTag report-table Version $Revision: 1.5 $
345 UserTag report-table Routine <<EOR
346 sub prep_cell {
347         my ($def,$datum,$linecount,$record) = @_;
348
349         #Debug("prep_cell datum: $datum");
350
351         my $cell;
352         if ($def->{filter}) {
353                 $datum = $Tag->filter({ op => $def->{filter}, }, $datum);
354         }
355
356         if ($def->{link}) {
357                 my $page_parms = { href => $def->{link}, };
358                 if ($def->{link_parm}) {
359                   $page_parms->{form} = $def->{link_parm} .'='. $record->{$def->{link_key}};
360                 }
361                 $cell = $Tag->page($page_parms);
362                 $cell .= $datum;
363                 $cell .= '</a>';
364         } elsif ($def->{widget}) {
365                 if ($def->{widget} =~ /^checkonly$/) {
366                         # This was a quick hack to support standalone checkboxes
367                         # for "delete/edit checked rows" type forms.
368                         my $checked = '';
369                         if ($datum) {
370                                 $checked = ' checked="checked"';
371                         }
372                         $cell = '<input type="checkbox" name="'. $def->{colname} .'_'. $linecount ."\" value=\"1\"$checked $Vend::Xtrailer>";
373                 } else {
374                         my $widget_name = $def->{colname} .'_'. $linecount;
375                         # We need to bludgeon Interchange over the head with the proper value
376                         # becuase set,default,value, and passed are ignored when there's an
377                         # existing value.
378                         $::Values->{$widget_name} = $datum;
379                         $cell = $Tag->widget($widget_name, {
380                                 type       => $def->{widget},
381                                 set        => $datum,
382                                 attribute  => $def->{widget_attribute},
383                                 db         => $def->{widget_db},
384                                 field      => $def->{widget_field},
385                                 extra      => $def->{widget_extra},
386                                 cols       => $def->{widget_cols},
387                                 rows       => $def->{widget_rows},
388                                 delimiter  => $def->{widget_delimiter},
389                                 key        => $def->{widget_key},
390                                 year_begin => $def->{widget_year_begin},
391                                 year_end   => $def->{widget_year_end},
392                                 filter     => $def->{widget_filter},
393                                 set        => $def->{widget_set},
394                                 });
395                 }
396         } else {
397                 $cell = $datum;
398         }
399
400         $cell = $def->{prefix} . $cell . $def->{postfix};
401
402         #Debug("prep_cell returning: $cell");
403
404         return $cell;
405 }
406
407 sub cell_open_tag {
408         my ($def,$rowspan,$colspan) = @_;
409
410         my @tag_parms;
411         push @tag_parms, "colspan=\"$colspan\"" if $colspan;
412         push @tag_parms, "rowspan=\"$rowspan\"" if $rowspan;
413         push @tag_parms, "class=\"$def->{class}\"" if $def->{class};
414         push @tag_parms, "width=\"$def->{width}\"" if $def->{width};
415         push @tag_parms, "valign=\"$def->{valign}\"" if $def->{valign};
416         push @tag_parms, "align=\"$def->{align}\"" if $def->{align};
417
418         my $type = $def->{header} ? 'th' : 'td';
419
420         if (@tag_parms) {
421                 return "<$type ". join( ' ', @tag_parms) .'>';
422         }
423
424         return '<td>';
425 }
426
427 sub {
428         #Debug("Entering report-table");
429         # Options gathering ------------------------------------------
430         my $opt = shift;
431
432         my @columns           = split ' ', $opt->{columns};
433         my @row_toggle        = split ',', $opt->{row_toggle};
434
435         if ($opt->{reset_horiz} eq '') {
436                 $opt->{reset_horiz} = 1;
437         }
438
439         if ($opt->{title_horiz} eq '') {
440                 $opt->{title_horiz} = 1;
441         }
442
443         if ($opt->{colheaders} eq '') {
444                 $opt->{colheaders} = 1;
445         }
446
447         #Debug("Gathered options. Query is: ". $opt->{query});
448
449         # Data structure preparation ---------------------------------
450         my @vertheads = ();
451         my @subheader_cols = ();
452
453         my (%cols,$column_defs);
454         if ($opt->{column_defs}) {
455                 $column_defs = eval( $opt->{column_defs} );
456                 %cols = %{$column_defs};
457         } else {
458                 foreach my $col (@columns) {
459                         $cols{$col}->{title} = $col;
460                 }
461         }
462
463         my @tcols;
464         my $headpos = 0;
465         foreach my $col (@columns) {
466                 if ($cols{$col}->{header}) {
467                         # Horizontal headers should never be in the 'columns' list
468                         if ($cols{$col}->{header} eq 'vert') {
469                                 $cols{$col}->{pos} = $headpos;
470                                 $headpos++;
471                                 push @subheader_cols, $col;
472                                 push @vertheads, $col;
473                                 $cols{$col}->{valign} ||= 'top';
474                         }
475                 } else {
476                         push @tcols, $col;
477                 }
478         }
479         foreach my $col (keys(%cols)) {
480                 $cols{$col}->{colname} = $col;
481                 $cols{$col}->{title} ||= $col;
482                 if ($cols{$col}->{header} =~ /horiz/) {
483                         push @subheader_cols, $col;
484                 }
485         }
486         @columns = @tcols;
487         # ----------------------------------------------------------##
488
489         my $output;
490         my $db = ::database_exists_ref('products');
491         my $results = $db->query({ sql => $opt->{query}, hashref => 'results' });
492
493         # Output column headers --------------------------------------
494         if (($results) and (@{$results}) and ($opt->{colheaders})) {
495                 $output .= '<tr class="headers">';
496
497                 foreach my $c (@vertheads) {
498                         $output .= "<th>$cols{$c}->{title}</th>";
499                 }
500                 foreach my $c (@columns) {
501                         $output .= "<th>$cols{$c}->{title}</th>";
502                 }
503                 $output .= "</tr>\n";
504         }
505
506         if (!(($results) and (@{$results}))) {
507                 return $opt->{no_results} || '<tr><td colspan="'. (scalar(@columns) + scalar(@vertheads)) .'">No results</td></tr>';
508         }
509         # ----------------------------------------------------------##
510
511         # Process results --------------------------------------------
512         my @rows = ();
513         my @vh_stack = ();   # Stack of vertical headers we're working on
514         my $vh;
515         my $rowcount = 0;
516         my $linecount = 1;
517         for (my $i = 0; $i < scalar(@{$results}); $i++) {
518                 if (@row_toggle) {
519                         next if !$row_toggle[$i];
520                 }
521                 my $record = $results->[$i];
522                 my $row;
523
524                 #Debug("Row: ". ::uneval($record));
525
526                 # Dynamic values that can be used as column data
527                 my %dynamic = (
528                         realrow    => $i,
529                         rowcount   => $rowcount,
530                         rownumber  => $linecount,
531                         linecount  => $linecount,
532                         parity     => $linecount % 2 ? 1 : 0,
533                 );
534
535                 $row->{dynamic} = \%dynamic;
536
537                 foreach my $subhead (@subheader_cols) {
538                         if ($record->{$subhead} ne $cols{$subhead}->{value}) {
539                           if ($cols{$subhead}->{header} ne 'vert') {
540                                 $row->{html} = cell_open_tag($cols{$subhead},0,$#columns + 1);
541
542                                 if ($opt->{title_horiz}) {
543                                         $row->{html} .= $cols{$subhead}->{title} .' ';
544                                 }
545                                 my $datum = $record->{$subhead};
546                                 $row->{html} .= prep_cell($cols{$subhead},$datum,$linecount,$record) .'</th>';
547                                 $cols{$subhead}->{value} = $record->{$subhead};
548                           } else {
549                                 # Vertical headers must be inserted at the end, because that's
550                                 # the only time we know what the rowspan is going to be.
551                                 # So we keep track of them with a stack and a notation in the
552                                 # row hash.
553                                 my $old;
554                                 if ($cols{$vh->{column}}->{pos} >= $cols{$subhead}->{pos}) {
555                                   while (($old->{column} ne $subhead) and (@vh_stack)) {
556                                         $old = pop @vh_stack;
557                                         $old->{end} = $rowcount;
558                                         $cols{$old->{column}}->{value} = '';
559                                         #::Debug("Popped vh_stack. Old is: ". ::uneval($old));
560                                   }
561                                 }
562                                 if ($opt->{reset_horiz}) {
563                                         # Don't let horizontal headers apply across vertical headers
564                                         foreach my $tmp (@subheader_cols) {
565                                                 if ($cols{$tmp}->{header} eq 'horiz') {
566                                                         $cols{$tmp}->{value} = '';
567                                                 }
568                                         }
569                                 }
570                                 my $datum = $record->{$subhead};
571                                 my $new = {
572                                         content => prep_cell($cols{$subhead},$datum,$linecount,$record),
573                                         column => $subhead,
574                                         begin => $rowcount,
575                                 };
576                                 push @vh_stack, $new;
577                                 #::Debug("vh_stack now: ". ::uneval(\@vh_stack));
578                                 unshift @{$row->{'vert_headers'}}, $new;
579                                 $cols{$subhead}->{value} = $record->{$subhead};
580                                 $vh = $new;
581                           }
582                           if ($row->{html}) {
583                                 push @rows, $row;
584                                 $rowcount++;
585                                 my %newrow = ();
586                                 $row = \%newrow;
587                           }
588                         }
589                 }
590                 if ($opt->{row_hidden_id}) {
591                         $row->{id} = $record->{$opt->{row_hidden_id}};
592                 }
593                 foreach my $col (@columns) {
594                         $row->{html} .= cell_open_tag($cols{$col});
595
596                         my $datum;
597                         if ($cols{$col}->{dynamic}) {
598                                 $datum = $dynamic{$cols{$col}->{dynamic}};
599                         } else {
600                                 $datum = $record->{$col};
601                         }
602                         if ((!$datum) and ($cols{$col}->{empty_cell})) {
603                                 $datum = $cols{$col}->{empty_cell};
604                         }
605
606                         $row->{html} .= prep_cell($cols{$col},$datum,$linecount,$record);
607
608                         $row->{html} .= '</td>';
609                 }
610
611                 push @rows, $row;
612                 $rowcount++;
613                 $linecount++;
614         }
615         # ----------------------------------------------------------##
616
617
618         # Do post-processing table assembly --------------------------
619         foreach my $row (@rows) {
620                 my $html = $row->{'html'};
621                 if ($row->{'vert_headers'}) {
622                         foreach my $vert (@{$row->{'vert_headers'}}) {
623                                 my $end = $vert->{end} || $rowcount;
624                                 my $cell = cell_open_tag($cols{$vert->{column}},$end - $vert->{begin});
625                                 $cell .= $vert->{content};
626                                 $cell .= '</th>';
627                                 $html = $cell . $html;
628                         }
629                 }
630                 my ($odd,$id);
631                 if ($row->{dynamic}->{parity}) {
632                         $odd = ' class="odd"';
633                 }
634                 if ($row->{id}) {
635                         my $name = $opt->{row_hidden_id} .'_'. $row->{dynamic}->{linecount};
636                         $id = "<input type=\"hidden\" name=\"$name\" value=\"$row->{id}\" $Vend::Xtrailer>";
637                 }
638                 $output .= "<tr$odd>$id$html</tr>\n";
639         }
640         # ----------------------------------------------------------##
641
642         # Set some side-effect scratch variables
643         if ($opt->{colheaders}) { $rowcount++; }
644         $Tag->tmp('report_table_rowcount',$rowcount);
645         $Tag->tmp('report_table_linecount',$linecount - 1);
646         $Tag->tmp('report_table_colspan',(scalar(@columns) + scalar(@vertheads)));
647
648         return $output;
649 }
650 EOR