/*

Client-side unobtrusive listview widget.

Copyright (C) 2009 Stephane Lavergne <www.imars.com>

This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.

This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA


The Listview widget formats a table of data to a familiar style (though fully
configurable), and allows the user to change the table's sorting order to any
column, ascending or descending.  It also allows filtering on any combination
of columns for any logical criteria.  Finally, it provides optional totals for
numeric columns and a line indicating how many rows are currently visible.

Be sure to use "listview.css" as your starting point to build a suitable CSS.
Pay close attention to the MSIE bug work-arounds.

Usage:

Just include this module in your imars.js build and use suitable CSS.  It's
then just a matter of setting your tables to class "listview".  For MSIE
compatibility, you must enclose those tables in divs of class "listview"
because MSIE doesn't allow scrolling TBODY elements.

You will need the first row of your THEAD to have each cell of a class
describing its data type, one of:

	"bool" is white space (incl.  ) false, anything more true
	"number" is a float
	"date" is YYYY/MM/DD or YYYY-MM-DD or YYYY.MM.DD
	"select" is short strings (searching will use a drop-down)
	"text" is a string to display multiline normally.
	"textbr" is a string to display in full, no-break.
	"subject" is a string to truncate to a single line (via CSS). Must be wrapped in a paragraph.

The script will automatically propagate those classes to each column's cells
in tbody, but if you're worried about looks on browsers with JavaScript
disabled, feel free to do so in your XHTML output.

If you include a TFOOT, you can give it one or many classes to tell Listview
what you want added there:

	totals: Add a row showing totals of all visible rows of numeric columns.
	filters: Add a row with inputs to search through rows.
	counter: Add a row stating how many rows are currently visible.

Example:
	<tfoot class="totals filters"> </tfoot>


Other notes:

This is a complete rewrite from the 2006 Listview widget, featuring:

* Compatible with MSIE (though crashy in 6?)
* Stores data 100% in the DOM
* Faster sorting and filtering
* No interference with other DHTML/AJAX scripts
* "SELECT MULTIPLE" behavior was removed

This new design uses the DOM's full potential and doesn't store any
information on its own.  Sorting and filtering large tables (several hundred
rows) is a bit faster now that we use display none vs '' and DOM reordering
functions.  Also, now if your other JavaScript code modifies the contents of
your data cells, this new Listview's actions will not interfere.  (You could
even add and delete rows, but not columns.)

Note that it is not as fast as it could be for sort/filter operations, because
data from each cell is re-read from its .innerHTML property each time it is
needed.  The performance drawback is tolerated specifically to make Listview
completely transparent: If your other JavaScript code modifies the contents of
any data cells, or in fact even adds or removes entire rows, Listview will
continue to work.

Note that if you actually add or remove rows and you use totals feature in
TFOOT, you will want to get them updated by manually calling
imars.dom.lv.refreshTotals(tableNode).  If you use the counter feature,
unfortunately it is not technically possible to update it accurately until the
user changes any sort criteria.


Developer notes:

Each table of class listview gets:
- lvSortCol: Which column index we're sorting with
- lvSortFactor: used to determine sort direction
- lvCounter: visible rows text node, if that total is to be displayed
- lvCols: Handy reference to tHead.rows[0].cells

Each th in the thead gets:
- lvClass: copy of className (used in resets)
- lvUniques: list of possible strings for a select column
- lvFilter: -1 (???)
- lvFilterArg: '' (???)
- lvClickMode: 'sort'
- lvColIndex: 0-n
- lvTable: Handy reference to the table node
- onclick is set
- lvTotal: total text node, if one is to be displayed

Each td in tbody gets:
- className: becomes that of its matching th

Generated select/input get:
- lvClickMode: 'filter' or 'filterarg'
- lvColIndex: 0-n
- lvTable: Handy reference to the table node

*/

imars.require("imars.dom");

imars.dom.lv = {

	// JavaScript's sort() doesn't allow enough parameters...
	// The idea here is that we'll be done sorting before something else is
	// attempted, so not being thread-safe is tolerable, albeit ugly compared
	// to the rest of this module.
	currentTable: false,

	load: function () {

		// Iterate through each Listview table.
		var tables = imars.dom.getElementsByClassName(document, 'table', 'listview');
		for (var i=0, iMax = tables.length; i < iMax; i++) {
			var table = tables[i];

			table.lvSortCol = 0;
			table.lvSortFactor = 1;

			table.lvCols = table.tHead.rows[0].cells;

			for (var j=0, jMax=table.lvCols.length; j < jMax; j++) {
				var col = table.lvCols[j];
				col.lvClass = col.className;
				if (col.className == 'select') col.lvUniques = [];
				col.lvFilter = -1;
				col.lvFilterArg = '';
				col.onclick = imars.dom.lv.onClick;
				col.lvClickMode = 'sort';
				col.lvColIndex = j;
				col.lvTable = table;
			};

			var rows = table.tBodies[0].rows;
			for (var j=0, jMax=rows.length; j < jMax; j++) {
				for (var k=0, kMax=rows[j].cells.length; k < kMax; k++) {
					var col = rows[j].cells[k];
					if (table.lvCols[k].lvClass == 'select') {
						// Add string to list of options if not found already.
						var found = 0;
						var cleaned = col.innerHTML.replace(/(<[^>]+>)|(&[a-z0-9]{1,6};)/ig,'');
						if (imars.dom.lv.iinArray(table.lvCols[k].lvUniques, cleaned) < 0) table.lvCols[k].lvUniques.push(cleaned);
					};
					col.className = table.lvCols[k].className;
				};
			};
			for (var j=0, jMax=table.lvCols.length; j < jMax; j++) {
				if (table.lvCols[j].lvUniques) table.lvCols[j].lvUniques.sort(imars.dom.lv.isort);
			};

			// Create footers as necessary
			if (table.tFoot) {

				// Calculate totals
				if (table.tFoot.className.search('totals') >= 0) {
					var totalsRow = document.createElement('tr');
					for (var j=0, jMax = table.lvCols.length; j < jMax; j++) {
						var newCol = document.createElement('td');
						if (table.lvCols[j].lvClass == 'number') {
							newCol.className = 'number';
							var newText = document.createTextNode(' ');
							newCol.appendChild(newText);
							table.lvCols[j].lvTotal = newText;
						};
						totalsRow.appendChild(newCol);
					};
					table.tFoot.appendChild(totalsRow);
					imars.dom.lv.refreshTotals(table);
				};

				// Display filter form
				if (table.tFoot.className.search('filters') >= 0) {
					var filterRow = document.createElement('tr');
					for (var j=0, jMax = table.lvCols.length; j < jMax; j++) {
						var newCol = document.createElement('td');
						if (table.lvCols[j].className == 'number'  ||  table.lvCols[j].className == 'date') {
							newCol.appendChild(imars.dom.create({
								tagName: 'select',
								lvClickMode: 'filter',
								lvColIndex: j,
								lvTable: table,
								lvClass: table.lvCols[j].lvClass,
								onkeyup: imars.dom.lv.onClick,
								onchange: imars.dom.lv.onClick,
								childNodes: [
									{ tagName: 'option', value: '-1', innerText: 'All' },
									{ tagName: 'option', value: 'lt', innerText: '<' },
									{ tagName: 'option', value: 'lte', innerText: '<=' },
									{ tagName: 'option', value: 'eq', innerText: '==' },
									{ tagName: 'option', value: 'gte', innerText: '>=' },
									{ tagName: 'option', value: 'gt', innerText: '>' }
								]
							}));
							newCol.appendChild(document.createElement('br'));
							newCol.appendChild(imars.dom.create({
								tagName: 'input',
								type: 'text',
								lvClickMode: 'filterarg',
								lvColIndex: j,
								lvTable: table,
								lvClass: table.lvCols[j].lvClass,
								onkeyup: imars.dom.lv.onClick
							}));
						} else if (table.lvCols[j].className == 'select') {
							newSelect = imars.dom.create({
								tagName: 'select',
								lvClickMode: 'filter',
								lvColIndex: j,
								lvTable: table,
								lvClass: table.lvCols[j].lvClass,
								onkeyup: imars.dom.lv.onClick,
								onchange: imars.dom.lv.onClick,
								childNodes: [
									{ tagName: 'option', value: '-1', innerText: 'All' }
								]
							});
							for (var k=0, kMax = table.lvCols[j].lvUniques.length; k < kMax; k++) {
								newSelect.appendChild(imars.dom.create({ tagName: 'option', value: table.lvCols[j].lvUniques[k], innerText: table.lvCols[j].lvUniques[k] }));
							};
							newCol.appendChild(newSelect);
						} else if (table.lvCols[j].className == 'bool') {
							newCol.appendChild(imars.dom.create({
								tagName: 'select',
								lvClickMode: 'filter',
								lvColIndex: j,
								lvTable: table,
								lvClass: table.lvCols[j].lvClass,
								onkeyup: imars.dom.lv.onClick,
								onchange: imars.dom.lv.onClick,
								childNodes: [
									{ tagName: 'option', value: '-1', innerText: 'All' },
									{ tagName: 'option', value: '1', innerText: 'Yes' },
									{ tagName: 'option', value: '2', innerText: 'No' }
								]
							}));
						} else {
							newCol.appendChild(imars.dom.create({
								tagName: 'select',
								lvClickMode: 'filter',
								lvColIndex: j,
								lvTable: table,
								lvClass: table.lvCols[j].lvClass,
								onkeyup: imars.dom.lv.onClick,
								onchange: imars.dom.lv.onClick,
								childNodes: [
									{ tagName: 'option', value: '-1', innerText: 'All' },
									{ tagName: 'option', value: 'starts', innerText: 'Starts with' },
									{ tagName: 'option', value: 'contains', innerText: 'Contains' },
									{ tagName: 'option', value: 'equals', innerText: 'Equals' }
								]
							}));
							newCol.appendChild(document.createElement('br'));
							newCol.appendChild(imars.dom.create({
								tagName: 'input',
								type: 'text',
								lvClickMode: 'filterarg',
								lvColIndex: j,
								lvTable: table,
								onkeyup: imars.dom.lv.onClick
							}));
						};
						filterRow.appendChild(newCol);
					};

					table.tFoot.appendChild(filterRow);
				};

				// Display counter
				if (table.tFoot.className.search('counter') >= 0) {
					var counterRow = document.createElement('tr');
					var counterCell = document.createElement('td');
					counterCell.colSpan = table.lvCols.length;
					var counterSpan = document.createElement('span');
					var newText = document.createTextNode(rows.length);
					counterSpan.appendChild(newText);
					table.lvCounter = newText;
					counterCell.appendChild(counterSpan);
					counterCell.appendChild(document.createTextNode(' rows'));
					counterRow.appendChild(counterCell);
					table.tFoot.appendChild(counterRow);
				};

			};

		};

	},


	onClick: function (e) {
		var node;
		if (e) {
			if (e.target) node = e.target; else if (e.nodeName) node = e; else return;
		} else {
			if (window.event) node = window.event.srcElement; else return;
		};

		if (!node.lvClickMode) return;

		switch (node.lvClickMode) {

			case 'sort':
				// If we're already sorting for our column, change direction.
				if (node.lvTable.lvSortCol == node.lvColIndex) {
					if (node.lvTable.lvSortFactor == 1) node.lvTable.lvSortFactor = -1;
					else node.lvTable.lvSortFactor = 1;
				} else {
					node.lvTable.lvSortCol = node.lvColIndex;
				};
				imars.dom.lv.reSort(node.lvTable);
				imars.dom.lv.refreshHeader(node.lvTable);
			break;

			case 'filter':
				node.lvTable.lvCols[node.lvColIndex].lvFilter = node.value;
				imars.dom.lv.reFilter(node.lvTable);
				imars.dom.lv.refreshTotals(node.lvTable);
			break;

			case 'filterarg':

				// If we have a value, set a default filter if necessary.
				// NOTE: This does NOT change the selected option in the form, which remains "All".
				if ((node.value != '')  &&  (node.lvFilter == -1)) {
					switch (node.lvTable.lvCols[node.lvColIndex].lvClass) {
						case 'number': node.lvFilter = 'eq'; break;
						case   'date': node.lvFilter = 'eq'; break;
						case 'select': break;
						case   'bool': break;
						default:       node.lvFilter = 'starts'; break;
					};
				};

				node.lvTable.lvCols[node.lvColIndex].lvFilterArg = node.value;
				imars.dom.lv.reFilter(node.lvTable);
				imars.dom.lv.refreshTotals(node.lvTable);
			break;

			default:
				alert('Unknown mode '+ mode);

		};

	},


	refreshHeader: function (table) {
		for (i=0, iMax=table.lvCols.length; i < iMax; i++) {
			if (i == table.lvSortCol) {
				if (table.lvSortFactor == 1) {
					table.lvCols[i].className = 'sortup';
				} else {
					table.lvCols[i].className = 'sortdown';
				};
			} else {
				table.lvCols[i].className = table.lvCols[i].lvClass;
			};
		};
	},


	reFilter: function (table) {
		var displayed = 0;
		var rows = table.tBodies[0].rows;
		for (var i=0, iMax=rows.length; i < iMax; i++) {
			var match = 1;
			for (var j=0, jMax=table.lvCols.length; j < jMax  &&  match == 1; j++) {
				var filter = table.lvCols[j].lvFilter;
				var filterarg = table.lvCols[j].lvFilterArg;

				switch (table.lvCols[j].lvClass) {

					case 'number':
						if (filterarg) switch (filter) {
							case 'lt': if (parseFloat(rows[i].cells[j].innerHTML) >= parseFloat(filterarg)) match = 0; break;
							case 'lte': if (parseFloat(rows[i].cells[j].innerHTML) > parseFloat(filterarg)) match = 0; break;
							case 'eq': if (parseFloat(rows[i].cells[j].innerHTML) != parseFloat(filterarg)) match = 0; break;
							case 'gt': if (parseFloat(rows[i].cells[j].innerHTML) <= parseFloat(filterarg)) match = 0; break;
							case 'gte': if (parseFloat(rows[i].cells[j].innerHTML) < parseFloat(filterarg)) match = 0; break;
						};
					break;

					case 'date':
						if (filterarg) switch (filter) {
							case 'lt': if (imars.dom.lv.toDate(rows[i].cells[j].innerHTML) >= imars.dom.lv.toDate(filterarg)) match = 0; break;
							case 'lte': if (imars.dom.lv.toDate(rows[i].cells[j].innerHTML) > imars.dom.lv.toDate(filterarg)) match = 0; break;
							case 'eq': if (imars.dom.lv.toDate(rows[i].cells[j].innerHTML) != imars.dom.lv.toDate(filterarg)) match = 0; break;
							case 'gt': if (imars.dom.lv.toDate(rows[i].cells[j].innerHTML) <= imars.dom.lv.toDate(filterarg)) match = 0; break;
							case 'gte': if (imars.dom.lv.toDate(rows[i].cells[j].innerHTML) < imars.dom.lv.toDate(filterarg)) match = 0; break;
						};
					break;

					case 'select':
						if ((filter != -1)  &&  (rows[i].cells[j].innerHTML.replace(/(<[^>]+>)|(&[a-z0-9]{1,6};)|(\s+)/ig,'').toUpperCase() != filter.replace(/(\s+)/ig,'').toUpperCase())) match = 0;
					break;

					case 'bool':
						switch (parseInt(filter)) {
							case 1: if (rows[i].cells[j].innerHTML.search(/^(\s|(&nbsp;))*$/i) >= 0) match = 0; break;
							case 2: if (rows[i].cells[j].innerHTML.search(/^(\s|(&nbsp;))*$/i) < 0) match = 0; break;
						};
					break;

					default:
						if (filterarg) switch (filter) {
							case 'starts': if (rows[i].cells[j].innerHTML.replace(/(<[^>]+>)|(&[a-z0-9]{1,6};)/ig,'').toUpperCase().indexOf(filterarg.toUpperCase()) != 0) match = 0; break;
							case 'equals': if (rows[i].cells[j].innerHTML.replace(/(<[^>]+>)|(&[a-z0-9]{1,6};)/ig,'').toUpperCase() != filterarg.toUpperCase()) match = 0; break;
							case 'contains': if (rows[i].cells[j].innerHTML.replace(/(<[^>]+>)|(&[a-z0-9]{1,6};)/ig,'').toUpperCase().search(filterarg.toUpperCase()) < 0) match = 0; break;
						};
					break;

				};
			};
			if (match == 1) {
				displayed++;
				if (rows[i].style.display == 'none') rows[i].style.display = '';
			} else {
				if (rows[i].style.display != 'none') rows[i].style.display = 'none';
			};
		};
		if (table.lvCounter) table.lvCounter.data = displayed;
	},


	reSort: function (table) {
		imars.dom.lv.currentTable = table;  // Work around JS limitation
		var cha = table.tBodies[0].rows;
		var children = [];
		for (i=0, iMax=cha.length; i < iMax; i++) {
			children.push(cha[i]);
		};
		cha = null;
		if (table.lvCols[table.lvSortCol].lvClass == 'number') {
			children.sort(imars.dom.lv.sortNumbers);
		} else {
			// Assuming YMD dates, which can be sorted as strings
			children.sort(imars.dom.lv.sortStrings);
		};
		for (i=0, iMax=children.length; i < iMax; i++) {
			table.tBodies[0].appendChild(children[i]);
		};
	},


	sortStrings: function (a,b) {
		// Compare case insensitive, skipping HTML tags. (Data comes from innerHTML.)
		var table = imars.dom.lv.currentTable;
		var as = a.cells[table.lvSortCol].innerHTML.replace(/(<[^>]+>)|(&[a-z0-9]{1,6};)/ig,'').toUpperCase();
		var bs = b.cells[table.lvSortCol].innerHTML.replace(/(<[^>]+>)|(&[a-z0-9]{1,6};)/ig,'').toUpperCase();
		if (as.toString() > bs.toString()) return 1 * table.lvSortFactor;
		else if (as < bs) return -1 * table.lvSortFactor;
		else return 0;
	},


	sortNumbers: function (a,b) {
		var table = imars.dom.lv.currentTable;
		var an = parseFloat(a.cells[table.lvSortCol].innerHTML);
		var bn = parseFloat(b.cells[table.lvSortCol].innerHTML);
		if (table.lvSortFactor == 1) return(an - bn);
		else return(bn - an);
	},


	refreshTotals: function (table) {
		// The core here is a parseFloat(cell.innerHTML).
		// I'm tempted to cache the result of this at startup so it's done only
		// once.  It is bound to be much faster at runtime, however if data
		// changed dynamically, our sorting and total wouldn't be updated.
		// In the spirit of making Listview truly DHTML/AJAX compatible and
		// fully unobtrusive like a native browser widget would be, I'm keeping
		// this intact for the initial release. No caching of anything.
		// Same goes for dates and what not, which would benefit immensely from
		// being cached as their integer counterparts.
		for (var k=0, kMax = table.lvCols.length; k < kMax; k++) {
			table.lvCols[k].lvTotalNum = 0;
		};
		var rows = table.tBodies[0].rows;
		for (var j=0, jMax = rows.length; j < jMax; j++) if (rows[j].style.display != 'none') {
			var cols = rows[j].cells;
			for (var k=0, kMax = cols.length; k < kMax; k++) if (table.lvCols[k].lvTotal) {
				table.lvCols[k].lvTotalNum += parseFloat(cols[k].innerHTML);
			};
		};
		for (var k=0, kMax = table.lvCols.length; k < kMax; k++) if (table.lvCols[k].lvTotal) {
			table.lvCols[k].lvTotal.data = table.lvCols[k].lvTotalNum;
		};
	},


	toDate: function (a) {
		var almost = a.replace(/[\s\.\-\/]+/ig,'');
		for (var i = almost.length; i < 8; i++) almost += "0";
		return(parseInt(almost));
	},


	isort: function (a,b) {
		// Compare case insensitive.
		var as = a.toString().toUpperCase();
		var bs = b.toString().toUpperCase();
		if (as > bs) return 1;
		else if (as < bs) return -1;
		else return 0;
	},

	iinArray: function(haystack, needle) {
		var found = -1;
		var ineedle = needle.toUpperCase();
		for (var i=0, iMax=haystack.length; i < iMax; i++) {
			if (haystack[i].toUpperCase() == ineedle) {
				found = i;
				break;
			};
		};
		return found;
	}

};
imars.onLoad(imars.dom.lv.load);
