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