
/*===============================================================================
	TableSorter.js
	John Larson
	9/08/08
	
	Add table sorting (via column heading click) to a given table.
	
	It is important to specify columnDataTypes in options if you need
	sorting to be anything other than lexicographical order on the text of a cell.
	
	Tables must contain <thead> and <tbody> tags.
	
	Example:
	<table id="clientInvoiceTable">
	 <thead>
	  <tr>
	    <th>Name</th>
	    <th>Total Billed</th>
	    <th>Total Paid</th>
	    <th>Outstanding</th>
	    <th>Due</th>
	  </tr>
	 </thead>
	 <tbody>
	  <tr class="odd">
	    <td>Rob Fieldhouse</td>
	    <td>$100.00</td>
	    <td>$0.00</td>
	    <td>$100.00</td>
	    <td>10/2/2008</td>
	  </tr>
	  <tr class="even">
	    <td>John Larson</td>
	    <td>$500.00</td>
	    <td>$300.00</td>
	    <td>$200.00</td>
	    <td>9/16/2008</td>
	  </tr>
	  <tr class="odd">
	    <td>Tom Robinson</td>
	    <td>$300.00</td>
	    <td>$5.00</td>
	    <td>$295.00</td>
	    <td>9/20/2008</td>
	  </tr>
	 </tbody>
	</table>
	
	mySorter = new TableSorter('clientInvoiceTable',
		{columnDataTypes: 'string, money, money, money, date',
		 sortColumn: 3, sortDirection: 'desc', cookieName: 'invoiceOverviewSort' });

===============================================================================*/


var TableSorter = new Class({
	
	Implements: [Options, Events],
	
	options: {
		isStripedTable:			true,
		rowsPerRecord:			1,
		oddRowClassName:		'odd',
		evenRowClassName:		'even',
		headerAscClass:			'sortAsc',
		headerDescClass:		'sortDesc',
		sortDirection:  		'asc',
		sortColumn:    			-1,
		cookieName:				null, 
		cookieDays:				999,
		footerRowClass:			'footer',
		exemptRowClass:			'sortExempt',
		sortAscIconImagePath:	'images/chrome/sortArrowUp.gif',
		sortDescIconImagePath:	'images/chrome/sortArrowDown.gif',
		columnDataTypes:		[]  // recognizes 'money', 'int', 'numeric', 'date'
	},
	
	initialize: function(table, options) {
		
		if(!$(table))
			throw('Unknown table passed to TableSorter contructor (' + table + ')');
		this.table = $(table);
		this.setOptions(options);
		
		
		var head = $(this.table.getElementsByTagName('thead')[0]);
		var body = $(this.table.getElementsByTagName('tbody')[0]);
		if(!head || !body)
			throw('Table must have a thead and tbody to be sortable');
		
		this.headerCells = head.getFirst().getChildren();
		this.headerCells.each(function(th, index) {
			th.columnIndex = index;
			th.addClass('clickable');
			var sortIcon = (new Element('img'));
			sortIcon.src = 'images/chrome/sortArrowDown.gif';
			sortIcon.columnIndex = index;
			th.adopt(sortIcon);
			th.sortIcon = sortIcon;
			sortIcon.style.display = 'none';
		});
		
		var columnCount = this.headerCells.length;
		this.tbody = body;
		
		// Build our set of data types--hints for comparison operations:
		var columnDataTypes = this.options.columnDataTypes || '';
		if($type(columnDataTypes) == 'string') {  // handled a comma-separated list
			columnDataTypes = columnDataTypes.split(/,\s*/);
		}
		// Ensure our array is as long as the number of columns we have:
		this.columnDataTypeSet = new Array(columnCount).complement(columnDataTypes);
		
		
		// Create a data set of records.  Records will consist of as many table
		// rows per as given by the rowsPerRecord option (most usually 1,
		// but common use case is 2 when there's a "details" row that is
		// connected to the rows above it).  The structure is this:
		//		record: {
		//			rowSet:		rowDomElements[],
		//			dataSet:	array of comparable-ready raw data values, one per column
		//		}
		var trSet = $$(this.tbody.getChildren());
		this.recordSet = [];
		this.footerRowSet = [];
		while (trSet.length > 0) {
			var thisRecordRow = trSet.shift();
			if(thisRecordRow.hasClass(this.options.footerRowClass)  ||
			   thisRecordRow.hasClass(this.options.exemptRowClass)) {
				this.footerRowSet.push(thisRecordRow);
			}
			else { // regular sortable record row!
				var thisRecordRowSet = [thisRecordRow];
				for(var i=0; i < this.options.rowsPerRecord-1; i++)
					thisRecordRowSet.push(trSet.shift());
				
				// now build our data structure of raw, comparison-ready data:
				var thisDataSet = new Array(columnCount);
				for(var c=0; c < columnCount; c++) {
					var pureData;
					try {
					var rawData = $(thisRecordRow.getChildren()[c]).getProperty('text');
					}catch(e) {
						throw('Row in table found with less td elements than header. ' +
							'Be certain your table rows are uniform, and that you have ' +
							'set rowsPerRecord correctly in the TableSorter constructor.');
					}
					switch(this.columnDataTypeSet[c]) {
						case 'money': case 'int': case 'numeric':
							var matchSet = rawData.match(/[\w\s]+([-+]?[0-9,]*\.?[0-9,]+)\.*/);
							if(matchSet)
								pureData = Number(matchSet[0].replace(/,/g, ''));
							else
								pureData = '';
							break;
						case 'date':
							pureData = Date.parse(rawData);
							break;
						default:
							pureData = rawData;
					}
					thisDataSet[c] = pureData;
				}
				this.recordSet.push({ rowSet: thisRecordRowSet, dataSet: thisDataSet });
			}
		}
		
		this.table.addEvent('click', this.handleHeaderClick.bind(this));
		
		this.sortColumn = this.options.sortColumn;
		this.sortDirection = this.options.sortDirection;
		
		var recalledSort = this.recallSortOrder();
		if(recalledSort) {
			var sortSet = recalledSort.split(' ');
			this.sortColumn = sortSet[0];
			this.sortDirection = sortSet[1];
		}
		
		if(this.sortColumn != -1) // we should sort!
			this.sortRows();
	},
	
	handleHeaderClick: function(event) {
		var element = event.target;
		if(!$defined(element.columnIndex)) // not a header cell click
			return;
		
		// as a response to the click, we'll adjust our sort state
		// given by sortColumn and sortDirection:
		if(this.sortColumn == element.columnIndex)
			this.sortDirection = (this.sortDirection == 'asc' ? 'desc' : 'asc');
		else {
			this.sortColumn = element.columnIndex;
			this.sortDirection = 'asc';
		}
		
		// Now we'll sort according to the new sort state:
		this.sortRows();
	},
	
	sortRows: function() {
		
		var sortColumn = this.sortColumn;
		var sortDirection = this.sortDirection;
		
	//	dbug.log('sortRows!  Column ' + sortColumn + ' ' + sortDirection);
		
		// First order of business: adjust the markings of the header:
		this.headerCells.each(function(th, index) {
			if(index == sortColumn) {
				th.sortIcon.style.display = '';
				th.sortIcon.src = sortDirection == 'asc' ?
					this.options.sortAscIconImagePath :
					this.options.sortDescIconImagePath;
				th.addClass(sortDirection == 'asc' ?
					this.options.headerAscClass :
					this.options.headerDescClass);
				th.removeClass(sortDirection == 'desc' ?
					this.options.headerAscClass :
					this.options.headerDescClass)
			}
			else {
				th.sortIcon.style.display = 'none';
				th.removeClass(this.options.headerAscClass);
				th.removeClass(this.options.headerDescClass);
			}
		}.bind(this));
		
		this.recordSet.sort(function(r1, r2) {
			var c1 = r1.dataSet[sortColumn];
			var c2 = r2.dataSet[sortColumn];
		//	dbug.log('compare ' + c1 + ' vs ' + c2);
			return (c1 < c2 ? -1 : (c2 < c1 ? 1 : 0));
		});
		if(sortDirection == 'desc')
			this.recordSet.reverse();
		
		// Now to rebuild the DOM according to our newly-sorted recordSet:
		var oddClass = this.options.oddRowClassName;
		var evenClass = this.options.evenRowClassName;
		var isStripedTable = this.options.isStripedTable;
		
		var theTableBody = this.tbody;
		this.recordSet.each(function(theRecord, index) {
			for(var i=0; i < theRecord.rowSet.length; i++) {
				var thisRow = theRecord.rowSet[i];
				if(isStripedTable) {
					if((index+1) % 2 == 1) { // odd row!
						thisRow.addClass(oddClass);
						thisRow.removeClass(evenClass);
					}
					else {
						thisRow.addClass(evenClass);
						thisRow.removeClass(oddClass);
					}
				}
				theTableBody.appendChild(thisRow);
			}
		});
		
		// Append the footer rows last, in order:
		for(var i=0; i < this.footerRowSet.length; i++)
			theTableBody.appendChild(this.footerRowSet[i]);
		
		this.saveSortOrder();
	},
	
	
	recallSortOrder: function(){
		return (this.options.cookieName) ? $pick(Cookie.get(this.options.cookieName), false) : false;
	},
	
	saveSortOrder: function(){
		if(this.options.cookieName) 
			Cookie.set(this.options.cookieName, 
				this.sortColumn + ' ' + this.sortDirection,
				{duration:this.options.cookieDays});
	}
	
});

