[classBrowser] Be more resilient to strange ordering decisions.
[awl.git] / inc / classBrowser.php
blob3382a33de59238c33a90b443430ad981cbd3421f
1 <?php
2 /**
3 * Table browser / lister class
5 * Browsers are constructed from BrowserColumns and can support sorting
6 * and other interactive behaviour. Cells may contain data which is
7 * formatted as a link, or the entire row may be linked through an onclick
8 * action.
10 * @package awl
11 * @subpackage Browser
12 * @author Andrew McMillan <andrew@catalyst.net.nz>
13 * @copyright Catalyst IT Ltd
14 * @license http://gnu.org/copyleft/gpl.html GNU GPL v2
16 require_once("AWLUtilities.php");
18 /**
19 * Ensure that this is not set elsewhere.
21 $BrowserCurrentRow = (object) array();
23 /**
24 * Return values from the current row for replacing into a template.
26 * This is used to return values from the current row, so they can
27 * be inserted into a row template. It is used as a callback
28 * function for preg_replace_callback.
30 * @param array of string $matches An array containing a field name as offset 1
32 function BrowserColumnValueReplacement($matches)
34 /**
35 * @global object $BrowserCurrentRow The row most recently read from the database.
37 global $BrowserCurrentRow;
38 // as usual: $matches[0] is the complete match
39 // $matches[1] the match for the first subpattern
40 // enclosed in '##...##' and so on
41 // Use like: $s = preg_replace_callback("/##([^#]+)##/", "BrowserColumnValueReplacement", $s);
42 // $BrowserCurrentRow needs to be assigned something relevant first...
44 $field_name = $matches[1];
45 if ( !isset($BrowserCurrentRow->{$field_name}) && substr($field_name,0,4) == "URL:" ) {
46 $field_name = substr($field_name,4);
47 $replacement = urlencode($BrowserCurrentRow->{$field_name});
49 else {
50 $replacement = $BrowserCurrentRow->{$field_name};
52 dbg_error_log( "Browser", ":BrowserColumnValueReplacement: Replacing %s with %s", $field_name, $replacement);
53 return $replacement;
57 /**
58 * BrowserColumns are the basic building blocks. You can specify just the
59 * field name, and the column header or you can get fancy and specify an
60 * alignment, format string, SQL formula and cell CSS class.
61 * @package awl
63 class BrowserColumn
65 var $Field;
66 var $Header;
67 var $Format;
68 var $Sql;
69 var $Align;
70 var $Class;
71 var $Type;
72 var $Translatable;
73 var $Hook;
74 var $current_row;
76 /**
77 * BrowserColumn constructor. Only the first parameter is mandatory.
79 * @param string field The name of the column in the SQL result.
80 * @param string header The text to appear in the column header on output
81 * (@see BrowserColumn::RenderHeader()). If this is not supplied then
82 * a default of the field name will be used.
83 * @param string align left|center|right - text alignment. Defaults to 'left'.
84 * @param string format A format (a-la-printf) to render data values within.
85 * (@see BrowserColumn::RenderValue()). If this is not supplied
86 * then the default will ensure the column value is displayed as-is.
87 * @param string sql Some SQL which will return the desired value to be presented as column 'field' of
88 * the result. If this is blank then the column is assumed to be a real data column.
89 * @param string class Additional classes to apply to the column header and column value cells.
90 * @param string datatype This will allow 'date' or 'timestamp' to preformat the field correctly before
91 * using it in replacements or display. Other types may be added in future.
92 * @param string $hook The name of a global function which will preprocess the column value
94 * The hook function should be defined as follows:
95 * function hookfunction( $column_value, $column_name, $database_row ) {
96 * ...
97 * return $value;
98 * }
100 function BrowserColumn( $field, $header="", $align="", $format="", $sql="", $class="", $datatype="", $hook=null ) {
101 $this->Field = $field;
102 $this->Sql = $sql;
103 $this->Header = $header;
104 $this->Format = $format;
105 $this->Class = $class;
106 $this->Align = $align;
107 $this->Type = $datatype;
108 $this->Translatable = false;
109 $this->Hook = $hook;
113 * GetTarget
115 * Retrieves a 'field' or '...SQL... AS field' definition for the target list of the SQL.
117 function GetTarget() {
118 if ( $this->Sql == "" ) return $this->Field;
119 return "$this->Sql AS $this->Field";
123 * RenderHeader
124 * Renders the column header cell for this column. This will be rendered as a <th>...</th>
125 * with class and alignment applied to it. Browser column headers are clickable, and the
126 * ordering will also display an 'up' or 'down' triangle with the column header that the SQL
127 * is sorted on at the moment.
129 * @param string order_field The name of the field currently being sorted on.
130 * @param string order_direction Whether the sort is Ascending or Descending.
131 * @param int browser_array_key Used this to help handle separate ordering of
132 * multiple browsers on the same page.
134 function RenderHeader( $order_field, $order_direction, $browser_array_key=0 ) {
135 global $c;
136 if ( $this->Align == "" ) $this->Align = "left";
137 $html = '<th class="'.$this->Align.'" '. ($this->Class == "" ? "" : "class=\"$this->Class\"") . '>';
139 $direction = 'A';
140 $image = "";
141 if ( $order_field == $this->Field ) {
142 if ( strtoupper( substr( $order_direction, 0, 1) ) == 'A' ) {
143 $image = 'down';
144 $direction = 'D';
146 else {
147 $image = 'up';
149 $image = "<img class=\"order\" src=\"$c->images/$image.gif\" alt=\"$image\" />";
151 if ( !isset($browser_array_key) || $browser_array_key == '' ) $browser_array_key = 0;
152 $html .= '<a href="'.replace_uri_params( $_SERVER['REQUEST_URI'], array( "o[$browser_array_key]" => $this->Field, "d[$browser_array_key]" => $direction ) ).'" class="order">';
153 $html .= ($this->Header == "" ? $this->Field : $this->Header);
154 $html .= "$image</a></th>\n";
155 return $html;
158 function SetTranslatable() {
159 $this->Translatable = true;
162 function RenderValue( $value, $extraclass = "" ) {
163 global $session;
164 global $BrowserCurrentRow;
166 if ( $this->Type == 'date' || $this->Type == 'timestamp') {
167 $value = $session->FormattedDate( $value, $this->Type );
170 if ( $this->Hook && function_exists($this->Hook) ) {
171 dbg_error_log( "Browser", ":Browser: Hook for $this->Hook on column $this->Field");
172 $value = call_user_func( $this->Hook, $value, $this->Field, $BrowserCurrentRow );
175 if ( $this->Translatable ) {
176 $value = translate($value);
179 $value = str_replace( "\n", "<br />", $value );
180 if ( substr(strtolower($this->Format),0,3) == "<td" ) {
181 $html = sprintf($this->Format,$value);
183 else {
184 // These quite probably don't work. The CSS standard for multiple classes is 'class="a b c"' but is lightly
185 // implemented according to some web references. Perhaps modern browsers are better?
186 $class = $this->Align . ($this->Class == "" ? "" : " $this->Class") . ($extraclass == "" ? "" : " $extraclass");
187 if ( $class != "" ) $class = ' class="'.$class.'"';
188 $html = sprintf('<td%s>',$class);
189 $html .= ($this->Format == "" ? $value : sprintf($this->Format,$value,$value));
190 $html .= "</td>\n";
192 $html = preg_replace_callback("/##([^#]+)##/", "BrowserColumnValueReplacement", $html );
193 return $html;
199 * Start a new Browser, add columns, set a join and Render it to create a basic
200 * list of records in a table.
201 * You can, of course, get a lot fancier with setting ordering, where clauses
202 * totalled columns and so forth.
203 * @package awl
205 class Browser
207 var $Title;
208 var $SubTitle;
209 var $FieldNames;
210 var $Columns;
211 var $HiddenColumns;
212 var $Joins;
213 var $Where;
214 var $Union;
215 var $Order;
216 var $OrderField;
217 var $OrderDirection;
218 var $OrderBrowserKey;
219 var $Grouping;
220 var $Limit;
221 var $Query;
222 var $BeginRow;
223 var $CloseRow;
224 var $BeginRowArgs;
225 var $Totals;
226 var $TotalFuncs;
227 var $ExtraRows;
230 * The Browser class constructor
232 * @param string $title A title for the browser (optional).
234 function Browser( $title = "" ) {
235 global $c;
236 $this->Title = $title;
237 $this->SubTitle = "";
238 $this->Order = "";
239 $this->Limit = "";
240 $this->BeginRow = "<tr class=\"row%d\">\n";
241 $this->CloseRow = "</tr>\n";
242 $this->BeginRowArgs = array('#even');
243 $this->Totals = array();
244 $this->Columns = array();
245 $this->HiddenColumns = array();
246 $this->FieldNames = array();
247 dbg_error_log( "Browser", ":Browser: New browser called $title");
251 * Add a column to the Browser.
253 * This constructs a new BrowserColumn, appending it to the array of columns
254 * in this Browser.
256 * Note that if the $format parameter starts with '<td>' the format will replace
257 * the column format, otherwise it will be used within '<td>...</td>' tags.
258 * @see BrowserColumn
260 * @param string $field The name of the field.
261 * @param string $header A column header for the field.
262 * @param string $align An alignment for column values.
263 * @param string $format A sprintf format for displaying column values.
264 * @param string $sql An SQL fragment for calculating the value.
265 * @param string $class A CSS class to apply to the cells of this column.
266 * @param string $hook The name of a global function which will preprocess the column value
268 * The hook function should be defined as follows:
269 * function hookfunction( $column_value, $column_name, $database_row ) {
270 * ...
271 * return $value;
275 function AddColumn( $field, $header="", $align="", $format="", $sql="", $class="", $datatype="", $hook=null ) {
276 $this->Columns[] = new BrowserColumn( $field, $header, $align, $format, $sql, $class, $datatype, $hook );
277 $this->FieldNames[$field] = count($this->Columns) - 1;
281 * Add a hidden column - one that is present in the SQL result, but for
282 * which there is no column displayed.
284 * This can be useful for including a value in (e.g.) clickable links or title
285 * attributes which is not actually displayed as a visible column.
287 * @param string $field The name of the field.
288 * @param string $sql An SQL fragment to calculate the field, if it is calculated.
290 function AddHidden( $field, $sql="" ) {
291 $this->HiddenColumns[] = new BrowserColumn( $field, "", "", "", $sql );
292 $this->FieldNames[$field] = count($this->Columns) - 1;
296 * Set the Title for the browse.
298 * This can also be set in the constructor but if you create a template Browser
299 * and then clone it in a loop you may want to assign a different Title for each
300 * instance.
302 * @param string $new_title The new title for the browser
304 function SetTitle( $new_title ) {
305 $this->Title = $new_title;
309 * Set the named columns to be translatable
311 * @param array $column_list The list of columns which are translatable
313 function SetTranslatable( $column_list ) {
314 $top = count($this->Columns);
315 for( $i=0; $i < $top; $i++ ) {
316 dbg_error_log( "Browser", "Comparing %s with column name list", $this->Columns[$i]->Field);
317 if ( in_array($this->Columns[$i]->Field,$column_list) ) $this->Columns[$i]->SetTranslatable();
319 $top = count($this->HiddenColumns);
320 for( $i=0; $i < $top; $i++ ) {
321 dbg_error_log( "Browser", "Comparing %s with column name list", $this->HiddenColumns[$i]->Field);
322 if ( in_array($this->HiddenColumns[$i]->Field,$column_list) ) $this->HiddenColumns[$i]->SetTranslatable();
327 * Set a Sub Title for the browse.
329 * @param string $sub_title The sub title string
331 function SetSubTitle( $sub_title ) {
332 $this->SubTitle = $sub_title;
336 * Set the tables and joins for the SQL.
338 * For a single table this should just contain the name of that table, but for
339 * multiple tables it should be the full content of the SQL 'FROM ...' clause
340 * (excluding the actual 'FROM' keyword).
342 * @param string $join_list
344 function SetJoins( $join_list ) {
345 $this->Joins = $join_list;
349 * Set a Union SQL statement.
351 * In rare cases this might be useful. It's currently a fairly simple hack
352 * which requires you to put an entire valid (& matching) UNION subclause
353 * (although without the UNION keyword).
355 * @param string $union_select
357 function SetUnion( $union_select ) {
358 $this->Union = $union_select;
362 * Set the SQL Where clause to a specific value.
364 * The WHERE keyword should not be included.
366 * @param string $where_clause A valide SQL WHERE ... clause.
368 function SetWhere( $where_clause ) {
369 $this->Where = $where_clause;
373 * Add an [operator] ... to the SQL Where clause
375 * You will generally want to call OrWhere or AndWhere rather than
376 * this function, but hey: who am I to tell you how to code!
378 * @param string $operator The operator to combine with previous where clause parts.
379 * @param string $more_where The extra part of the where clause
381 function MoreWhere( $operator, $more_where ) {
382 if ( $this->Where == "" ) {
383 $this->Where = $more_where;
384 return;
386 $this->Where = "$this->Where $operator $more_where";
390 * Add an OR ... to the SQL Where clause
392 * @param string $more_where The extra part of the where clause
394 function AndWhere( $more_where ) {
395 $this->MoreWhere("AND",$more_where);
399 * Add an OR ... to the SQL Where clause
401 * @param string $more_where The extra part of the where clause
403 function OrWhere( $more_where ) {
404 $this->MoreWhere("OR",$more_where);
407 function AddGrouping( $field, $browser_array_key=0 ) {
408 if ( $this->Grouping == "" )
409 $this->Grouping = "GROUP BY ";
410 else
411 $this->Grouping .= ", ";
413 $this->Grouping .= clean_string($field);
418 * Add an ordering to the browser widget.
420 * The ordering can be overridden by GET parameters which will be
421 * rendered into the column headers so that a user can click on
422 * the column headers to control the actual order.
424 * @param string $field The name of the field to be ordered by.
425 * @param string $direction A for Ascending, otherwise it will be descending order.
426 * @param string $browser_array_key Use this to distinguish between multiple
427 * browser widgets on the same page. Leave it empty if you only
428 * have a single browser instance.
429 * @param string $secondary Use this to indicate a default secondary order
430 * which shouldn't interfere with the default primary order.
432 function AddOrder( $field, $direction, $browser_array_key=0, $secondary=0 ) {
433 if ( $secondary == 0 && ( is_array($field) || is_array($direction) ) ) {
434 if ( $browser_array_key >= count($field) || $browser_array_key >= count($direction) ) return;
435 $field = $field[$browser_array_key];
436 $direction = $direction[$browser_array_key];
438 $field = clean_string($field);
439 if ( ! isset($this->FieldNames[$field]) ) return;
441 if ( $this->Order == "" )
442 $this->Order = "ORDER BY ";
443 else
444 $this->Order .= ", ";
446 if ( $secondary == 0 ) {
447 $this->OrderField = $field;
448 $this->OrderBrowserKey = $browser_array_key;
450 $this->Order .= $field;
452 if ( preg_match( '/^A/i', $direction) ) {
453 $this->Order .= " ASC";
454 if ( $secondary == 0)
455 $this->OrderDirection = 'A';
457 else {
458 $this->Order .= " DESC";
459 if ( $secondary == 0)
460 $this->OrderDirection = 'D';
466 * Mark a column as something to be totalled. You can also specify the name of
467 * a function which may modify the value before the actual totalling.
469 * The callback function will be called with each row, with the first argument
470 * being the entire record object and the second argument being only the column
471 * being totalled. The callback should return a number, to be added to the total.
473 * @param string $column_name The name of the column to be totalled.
474 * @param string $total_function The name of the callback function.
476 function AddTotal( $column_name, $total_function = false ) {
477 $this->Totals[$column_name] = 0;
478 if ( $total_function != false ) {
479 $this->TotalFuncs[$column_name] = $total_function;
485 * Set the format for an output row.
487 * The row format is set as an sprintf format string for the start of the row,
488 * and a plain text string for the close of the row. Subsequent arguments
489 * are interpreted as names of fields, the values of which will be sprintf'd
490 * into the beginrow string for each row.
492 * Some special field names exist beginning with the '#' character which have
493 * 'magic' functionality, including '#even' which will insert '0' for even
494 * rows and '1' for odd rows, allowing a nice colour alternation if the
495 * beginrow format refers to it like: 'class="r%d"' so that even rows will
496 * become 'class="r0"' and odd rows will be 'class="r1"'.
498 * At present only '#even' exists, although other magic values may be defined
499 * in future.
501 * @param string $beginrow The new printf format for the start of the row.
502 * @param string $closerow The new string for the close of the row.
503 * @param string $rowargs ... The row arguments which will be sprintf'd into
504 * the $beginrow format for each row
506 function RowFormat( $beginrow, $closerow, $rowargs )
508 $argc = func_num_args();
509 $this->BeginRow = func_get_arg(0);
510 $this->CloseRow = func_get_arg(1);
512 $this->BeginRowArgs = array();
513 for( $i=2; $i < $argc; $i++ ) {
514 $this->BeginRowArgs[] = func_get_arg($i);
520 * This method is used to build and execute the database query.
522 * You need not call this method, since Browser::Render() will call it for
523 * you if you have not done so at that point.
525 * @return boolean The success / fail status of the PgQuery::Exec()
527 function DoQuery() {
528 $target_fields = "";
529 foreach( $this->Columns AS $k => $column ) {
530 if ( $target_fields != "" ) $target_fields .= ", ";
531 $target_fields .= $column->GetTarget();
533 if ( isset($this->HiddenColumns) ) {
534 foreach( $this->HiddenColumns AS $k => $column ) {
535 if ( $target_fields != "" ) $target_fields .= ", ";
536 $target_fields .= $column->GetTarget();
539 $where_clause = ((isset($this->Where) && $this->Where != "") ? "WHERE $this->Where" : "" );
540 $sql = sprintf( "SELECT %s FROM %s %s %s ", $target_fields,
541 $this->Joins, $where_clause, $this->Grouping );
542 if ( "$this->Union" != "" ) {
543 $sql .= "UNION $this->Union ";
545 $sql .= $this->Order . ' ' . $this->Limit;
546 $this->Query = new PgQuery( $sql );
547 return $this->Query->Exec("Browse:$this->Title:DoQuery");
551 * Add an extra arbitrary row onto the end of the browser.
553 * @var array $column_values Contains an array of named fields, hopefully matching the column names.
555 function AddRow( $column_values ) {
556 if ( !isset($this->ExtraRows) || typeof($this->ExtraRows) != 'array' ) $this->ExtraRows = array();
557 $this->ExtraRows[] = &$column_values;
563 * This method is used to render the browser as HTML. If the query has
564 * not yet been executed then this will call DoQuery to do so.
566 * The browser (including the title) will be displayed in a div with id="browser" so
567 * that you can style '#browser tr.header', '#browser tr.totals' and so forth.
569 * @param string $title_tag The tag to use around the browser title (default 'h1')
570 * @return string The rendered HTML fragment to display to the user.
572 function Render( $title_tag = 'h1', $subtitle_tag = 'h2' ) {
573 global $c, $BrowserCurrentRow;
575 if ( !isset($this->Query) ) $this->DoQuery(); // Ensure the query gets run before we render!
577 dbg_error_log( "Browser", ":Render: browser $this->Title");
578 $html = '<div id="browser">';
579 if ( $this->Title != "" ) {
580 $html .= "<$title_tag>$this->Title</$title_tag>\n";
582 if ( $this->SubTitle != "" ) {
583 $html .= "<$subtitle_tag>$this->SubTitle</$subtitle_tag>\n";
586 $html .= "<table id=\"browse_table\">\n";
587 $html .= "<thead><tr class=\"header\">\n";
588 foreach( $this->Columns AS $k => $column ) {
589 $html .= $column->RenderHeader( $this->OrderField, $this->OrderDirection, $this->OrderBrowserKey );
591 $html .= "</tr></thead>\n<tbody>";
593 while( $BrowserCurrentRow = $this->Query->Fetch() ) {
595 // Work out the answers to any stuff that may be being substituted into the row start
596 foreach( $this->BeginRowArgs AS $k => $fld ) {
597 if ( isset($BrowserCurrentRow->{$fld}) ) {
598 $rowanswers[$k] = $BrowserCurrentRow->{$fld};
600 else {
601 switch( $fld ) {
602 case '#even':
603 $rowanswers[$k] = ($this->Query->rownum % 2);
604 break;
605 default:
606 $rowanswers[$k] = $fld;
610 // Start the row
611 $html .= vsprintf( $this->BeginRow, $rowanswers);
613 // Each column
614 foreach( $this->Columns AS $k => $column ) {
615 $html .= $column->RenderValue($BrowserCurrentRow->{$column->Field});
616 if ( isset($this->Totals[$column->Field]) ) {
617 if ( isset($this->TotalFuncs[$column->Field]) && function_exists($this->TotalFuncs[$column->Field]) ) {
618 // Run the amount through the callback function $floatval = my_function( $row, $fieldval );
619 $this->Totals[$column->Field] += $this->TotalFuncs[$column->Field]( $BrowserCurrentRow, $BrowserCurrentRow->{$column->Field} );
621 else {
622 // Just add the amount
623 $this->Totals[$column->Field] += $BrowserCurrentRow->{$column->Field};
628 // Finish the row
629 $html .= $this->CloseRow;
632 if ( count($this->Totals) > 0 ) {
633 $BrowserCurrentRow = (object) "";
634 $html .= "<tr class=\"totals\">\n";
635 foreach( $this->Columns AS $k => $column ) {
636 if ( isset($this->Totals[$column->Field]) ) {
637 $html .= $column->RenderValue( $this->Totals[$column->Field], "totals" );
639 else {
640 $html .= $column->RenderValue( "" );
643 $html .= "</tr>\n";
647 if ( count($this->ExtraRows) > 0 ) {
648 foreach( $this->ExtraRows AS $k => $v ) {
649 $BrowserCurrentRow = (object) $v;
650 // Work out the answers to any stuff that may be being substituted into the row start
651 foreach( $this->BeginRowArgs AS $k => $fld ) {
652 if ( isset( $BrowserCurrentRow->{$fld} ) ) {
653 $rowanswers[$k] = $BrowserCurrentRow->{$fld};
655 else {
656 switch( $fld ) {
657 case '#even':
658 $rowanswers[$k] = ($this->Query->rownum % 2);
659 break;
660 default:
661 $rowanswers[$k] = $fld;
666 // Start the row
667 $html .= vsprintf( $this->BeginRow, $rowanswers);
669 // Each column
670 foreach( $this->Columns AS $k => $column ) {
671 $html .= $column->RenderValue( (isset($BrowserCurrentRow->{$column->Field}) ? $BrowserCurrentRow->{$column->Field} : '') );
674 // Finish the row
675 $html .= $this->CloseRow;
679 $html .= "</tbody>\n</table>\n";
680 $html .= '</div>';
682 return $html;