Tabular reports: WATableReport

Dan Winkler, Radoslav Hodnicak and David Shaffer

Credits and original work

This text and code is adapted, modified and extended from the following two e-mails on the Seaside mailling list: Rado's hints and Dan's additional help. Thanks Dan and Rado for providing a helpful start to this tutorial.

Building a simple report

Seaside provides WATableReport as a simple but useable component for presenting tabular data. While it lacks some flexibility in terms of rendering (particularly style tagging to support justification and the ability to render arbitrary components into the table), it is often good enough for simple applications and makes a great starting place for more complicated HTML reports. WATableReport is directly useable for building sortable tables containing text or links. With subclassing it can be extended to include images or input components in your tables as well. In this tutorial we will look first at building a simple sortable tablular report and then at including web drill-down capabilities to reports. We finish by showing some useful extensions.

We begin by creating a component showing a "table of factorials". First the component class:

WAComponent subclass: #FactorialTableComponent
    instanceVariableNames: 'report'
    classVariableNames: ''
    poolDictionaries: ''
    category: 'WATableReportExample'
next we add a canBeRoot class side method
canBeRoot
	^true
Now our component will be visible in the Seaside config tool so we can make it the root of an application. Go ahead and create an application which has this component as a root so we can test it. Now we wish to create an instance of WATableReport (which we will store in our report instance variable). WATableReport instances need a data set (the "rows") and a collection of columns which essentially tell how to produce the text for that column's items. In our case the data set are simply the numbers 1 to 6. We want to have three columns, one which displays the number itself, one which sends it factorial and displays the result and one which displays "even" if the number is even and "odd" of the number is odd. We build up the table in the initialize method below.
initialize
    | rows columns |
    
    rows := #(1 2 3 4 5 6).
    columns := OrderedCollection new
         add: (WAReportColumn selector: #yourself title: 'original');         "selector gets perform:-ed on your data item"
         add: (WAReportColumn selector: #factorial title: 'factorial');
        add: (WAReportColumn new                                      "or if you want more control"
               valueBlock: [:rowItem | rowItem even ifTrue: ['even'] ifFalse: ['odd']];
               title: 'odd/even');
         yourself.

    report := WATableReport new rows: rows; columns: columns; yourself. 
Notice that WAReportColumn can either use a selector directly to get a column's data from your row data or it can use a block. Now let's arrange for this component to be displayed by implementing children and renderContentOn: as follows:
children
	^Array with: report

renderContentOn: html
	html render: report
You should now be able to view your component. Notice that every three rows alternate between a white and a yellow background (actually, on my monitor the yellow is barely noticeable). If you want to change the period of the alternation send rowPeriod: to the report instance (in initialize) and if you want to change the colors send rowColors: with an array of string colors. For example, adding
   report rowPeriod: 2.
   report rowColors: {'white'. 'lightgreen'}.
makes a greenbar style report but with only two text lines per region. If you supply more colors then they will be included in the alternation. If you want the whole report white, just sent report rowColors: {'white'}. If you view your application now it should look like this. Notice that each column can be sorted by clicking on the column heading. Only one active "sort" column is supported and no visual indication is given showing which column is controlling the sort order. In order for sorting to work, your row objects must understand <= or you must specify a different sortBlock for that column.

Now, all of us with bosses (pointy-haired or not!) know that numerical columns must be right aligned. We'll just right align all of the cells for now.

style
	^ 'td {text-align: right;}'
The final result should look something like

The complete source to this example can be loaded by filing in this file.

Drilling down

Most web-based reports support drill-down capabilities. The user can click on a row or even a specific cell in the table and be presented with more information about that item. This is supported in WAReportColumn by specifying a "click block" for that column. WATableReport will display items in that column as anchors allowing the user to click to presumably see more detail. In our example we add a click block to the number column. Modify the first column created in initialize to look as follows:
       add: ((WAReportColumn selector: #yourself title: 'original')
					clickBlock: [:row | self tellMeMoreAbout: row];
					 yourself);
and implement
tellMeMoreAbout: aNumber
	self inform: 'The number ' , aNumber printString , ' is a very pretty number.'
OK, not very interesting but you get the idea. In the callback we could display more details about the row in question. In this case our row data are numbers so we could display more information about the number. If you view the report you will see that the first column data are presented as anchors which invoke our callback block when clicked. Note that no matter which column you add the click block to, the row data is passed as the argument to the block (not the value of that specific column). This is normally what you want.

Formatting your data

Each column uses a format block to produce text from the cell's data. The default value of this block is [:x | x asString] so by default your cell's value is sent asString and the result is displayed as text. (Note this method should return a String or something which produces the desired response to asString. This method should not try to produce html since you will not get the desired result.) The format block for a column can be changed using formatBlock:. Suppose, for example, that we want the factorial data displayed in binary. We would modify the line which adds the factorial column in initialize to:
      add: ((WAReportColumn selector: #factorial title: 'factorial')
	formatBlock: [:item | item printStringBase: 2];
	 yourself);
Probably the most common use of this is to format dates. For example the format block [:item | item mmddyyyy] displays dates in the format 'month/day/year'. Look at the Date class for other formats. We have no particular use for dates in our example.

Summary row

WAReportColumn has a simple but generally useful mechanism for providing a summary row at the end of a table. If a column is flagged with hasTotal: true then the sum of all the values in the row will be displayed. This typically only works for numeric columns since it uses Collection>>detectSum:. Suppose we want to total the number and factorial values (no longer in base 2), change the lines above to:
	add: ((WAReportColumn selector: #yourself title: 'original') hasTotal: true;
					 yourself);
				 add: ((WAReportColumn selector: #factorial title: 'factorial') hasTotal: true;
					 yourself);
The summary row is displayed using HTML table header tags. This doesn't produce a very nice looking table in this example but this can be solved with some CSS by adding the following method:
style
	^ 'td {text-align: right;}
	tr+tr>th {text-align: right; border-top: 1px dashed blue;}'
The footer and header both use th tags so our CSS selector to pick out the footer is a little longer than you might have expected. Our report now looks like this:

Enhancements that I find useful

Here I present extensions (through subclassing only!) to WATableReport and WAReportColumn which I think maintain the spirit of this framework but make it easier to produce better looking reports. The two changes that I'd like to affect are: more useful style information in the table and more control over what is rendered in a cell (to permit images etc). In the current design it seems that too much of the rendering responsibility falls on WATableReport rather than on WAReportColumn (*). So, I subclass each:
WATableReport subclass: #SCTableReport
	instanceVariableNames: ''
	classVariableNames: ''
	poolDictionaries: ''
	category: 'SC-Components'
WAReportColumn subclass: #SCReportColumn
	instanceVariableNames: 'cssClass '
	classVariableNames: ''
	poolDictionaries: ''
	category: 'SC-Components'
Now in SCTableReport I override the following method
renderColumn: aColumn row: aRow on: html 
	aColumn renderCellForRow: aRow on: html
and in SCReportColumn I implement it as follows:
renderCellForRow: aRow on: html 
	| text |
	text _ self textForRow: aRow.
	text isEmpty
		ifTrue: [text _ ' '].
	html
		tableData: [self canChoose
				ifTrue: [html
						anchorWithAction: [self chooseRow: aRow]
						text: text]
				ifFalse: [html text: text]]
This is essentially the original implementation of WATableReport>>renderColumn:row:on: but now rendering is the responsibility of the column. At this point I've made no changes in functionality and the "SC" components should work just like the "WA" components, that is, the new "SC" components work exactly like the "WA" components. Now we start to make changes. First accessor/mutator for the cssClass i-var:
cssClass: anObject 
	cssClass _ anObject

cssClass
	^ cssClass
Now we modify our rendering method to use this class:
renderCellForRow: aRow on: html 
	| text |
	text _ self textForRow: aRow.
	text isEmpty
		ifTrue: [text _ ' '].
	cssClass ifNotNil: [html cssClass: cssClass].
	html
		tableData: [self canChoose
				ifTrue: [html
						anchorWithAction: [self chooseRow: aRow]
						text: text]
				ifFalse: [html text: text]]
Now we can change our table so that the parity column is left justified (it text, after all). There are two changes, the first is to add a call to cssClass: in our initialize method:
initialize
    | rows columns |
    
    rows := #(1 2 3 4 5 6).
    columns := OrderedCollection new
         add: ((SCReportColumn selector: #yourself title: 'original') hasTotal: true;
					yourself);
         add: ((SCReportColumn selector: #factorial title: 'factorial') hasTotal: true;
					yourself);
        add: (SCReportColumn new                                      "or if you want more control"
               valueBlock: [:rowItem | rowItem even ifTrue: ['even'] ifFalse: ['odd']];
               title: 'odd/even';
               cssClass: 'parity');
         yourself.

    report := SCTableReport new rows: rows; columns: columns; yourself.
    report rowPeriod: 2.
    report rowColors: {'white'. 'lightgreen'}. 
and change our style method to left justify that column:
style
	^ 'td {text-align: right;}
	tr+tr>th {text-align: right; border-top: 1px dashed blue;}
	td.parity {text-align: left;}'
So now our report looks like:

Similarly we should move rendering of the header and footer over to the column class. Add the following to SCTableReport:
renderTableHeaderOn: html 
	html cssClass: 'header'.
	super renderTableHeaderOn: html

renderHeaderForColumn: aColumn on: html 
	aColumn
		renderHeaderOn: html
		sortBlock: [self sortColumn: aColumn]

renderTableFooterOn: html 
	html cssClass: 'footer'.
	super renderTableFooterOn: html

renderFooterForColumn: aColumn on: html 
	aColumn renderFooterOn: html forRows: rows
Now we put the header and footer code in (adding the CSS classes) in SCReportColumn:
renderHeaderOn: html sortBlock: aBlock 
	cssClass
		ifNotNil: [html cssClass: cssClass].
	html
		tableHeading: [self canSort
				ifTrue: [html anchorWithAction: aBlock text: self title]
				ifFalse: [html text: self title]]

renderFooterOn: html forRows: rows 
	cssClass
		ifNotNil: [html cssClass: cssClass].
	html
		tableHeading: [html
				text: (self totalForRows: rows)]
Now we have a lot easier time assigning styles to a specific header or footer cell. In this particular example this allows us to change our simple style method to
style
	^ 'td {text-align: right;}
	tr.footer th {text-align: right; border-top: 1px dashed blue;}
	td.parity {text-align: left;}'
Really not much of a technical simplification but it does make our intentions more clear. While we're thinking about styles...what should we do about the sort column? Personally I like some visual indicator of which column is controling the sort but just giving it a CSS class doesn't sound like enough. What if we want to decorate it with a graphical arrow...I guess that's a style decision but I don't like injecting content using CSS. Rather than produce the most flexible solution here I opted for the one I wanted for my particular application. I simply added a "+" (plus) or "-" (or minus) sign in parentheses to the column label if it is controlling the sort. Here's the change to SCTableReport
renderHeaderForColumn: aColumn on: html 
	aColumn
		renderHeaderOn: html
		sortBlock: [self sortColumn: aColumn]
		sorted: aColumn == self sortColumn
		reversed: self isReversed
and for SCReportColumn
renderHeaderOn: html sortBlock: aBlock sorted: sorted reversed: reversed 
	| sortedString |
	sortedString _ sorted
				ifTrue: [reversed
						ifTrue: ['(-)']
						ifFalse: ['(+)']]
				ifFalse: [''].
	cssClass
		ifNotNil: [html cssClass: cssClass].
	html
		tableHeading: [self canSort
				ifTrue: [html anchorWithAction: aBlock text: self title , sortedString]
				ifFalse: [html text: self title , sortedString]]
You can get rid of SCReportColumn>>renderHeaderOn:sortBlock: since it is no longer used. Here's what the report looks like when reverse sorted by the first column:

One last improvement: images in cells. At first I thought I needed to be able to render any kind of HTML content into a cell. In practice the only non-text content I've needed was images but but I have used the rendering control to color cells etc.

Basically we add a render block to SCReportColumn. This three argument block is responsible for rendering the td element completely. The one provided by initialize (below) shows the default text rendering mechanism.

WAReportColumn subclass: #SCReportColumn
	instanceVariableNames: 'cssClass renderBlock '
	classVariableNames: ''
	poolDictionaries: ''
	category: 'SC-Components'

initialize
	super initialize.
	renderBlock _ [:row :col :html | html
				tableData: [self canChoose
						ifTrue: [html
								anchorWithAction: [self chooseRow: row]
								text: (self textForRow: row)]
						ifFalse: [html
								text: (self textForRow: row)]]]

renderBlock
	^ renderBlock

renderBlock: anObject 
	renderBlock _ anObject

textForRow: row 
	| text |
	text _ formatBlock
				value: (self valueForRow: row).
	text
		ifEmpty: [^ ' '].
	^ text

renderCellForRow: aRow on: html 
	self cssClass
		ifNotNil: [html cssClass: self cssClass].
	self renderBlock
		value: aRow
		value: self
		value: html

Now if all went well your report should look exactly the same. Now add a custom render block to the "even/odd" column that paints odd cells red. Modify FactorialTableComponent>>initialize so that this column gets added as below.
 add: (SCReportColumn new
	valueBlock: [:rowItem | rowItem even
			ifTrue: ['even']
			ifFalse: ['odd']];
	 title: 'odd/even';
	
	renderBlock: [:row :col :html | 
		| text | 
		text _ col textForRow: row.
		text = 'odd'
			ifTrue: [html attributeAt: 'bgcolor' put: 'red'].
		html tableData: text];
	 cssClass: 'parity');
Notice that I ignore canChoose since I know that there is no click block for this column. Render blocks can be quite messy and I don't recommend the "one huge initialize method" approach I've used here. The final hideous result looks like

The complete source for my extensions can be found in SCTableReport.st and SCReportColumn.st and the final FactorialTableComponent source code is here.

(*) Avi Bryant offers a quite reasonable explanation for the existing design here. After using this class quite a bit I would choose a diffent model class (something like the name TableModel would imply) if a strict MVC design is desired.