// vim: et:ts=4:sw=4:sts=4:ft=javascript /*@ Ox.TextList TextList Object () -> TextList Object (options) -> TextList Object (options, self) -> TextList Object options Options object columns <[o]|[]> Columns # Fixme: There's probably more... addable ... editable ... format ... id ... removable ... map function that maps values to sort values operator default sort operator title ... unique If true, this column acts as unique id visible ... width ... columnsMovable If true, columns can be re-ordered columnsRemovable If true, columns are removable columnsResizable If true, columns are resizable columnsVisible If true, columns are visible columnWidth <[n]|[40, 800]> Minimum and maximum column width draggable If true, items can be dragged id items function() {} {sort, range, keys, callback} or array keys <[s]|[]> Additional keys (apart from keys of visible columns) max Maximum number of items that can be selected (-1 for all) min Minimum number of items that must be selected pageLength Number of items per page scrollbarVisible If true, the scrollbar is always visible selected sort sortable If true, elements can be re-ordered self shared private variable @*/ // fixme: options.columnsMovable, but options.sortable ... pick one. Ox.TextList = function(options, self) { // fixme: rename to TableList // fixme: in columns, "operator" should be "sortOperator" self = self || {}; var that = Ox.Element({}, self) .defaults({ columns: [], columnsMovable: false, columnsRemovable: false, columnsResizable: false, columnsVisible: false, columnWidth: [40, 800], draggable: false, id: '', items: null, // function() {} {sort, range, keys, callback} or array keys: [], max: -1, min: 0, pageLength: 100, scrollbarVisible: false, selected: [], sort: [], sortable: false }) .options(options || {}) .addClass('OxTextList') .bindEvent({ key_left: function() { var $element = that.$body.$element, scrollLeft = $element[0].scrollLeft - $element.width(); $element.animate({scrollLeft: scrollLeft}, 250); }, key_right: function() { var $element = that.$body.$element, scrollLeft = $element[0].scrollLeft + $element.width(); $element.animate({scrollLeft: scrollLeft}, 250); }, keys: find }); self.options.columns.forEach(function(column) { // fixme: can this go into a generic ox.js function? // fixme: and can't these just remain undefined? if (Ox.isUndefined(column.align)) { column.align = 'left'; } if (Ox.isUndefined(column.clickable)) { column.clickable = false; } if (Ox.isUndefined(column.editable)) { column.editable = false; } if (Ox.isUndefined(column.unique)) { column.unique = false; } if (Ox.isUndefined(column.visible)) { column.visible = false; } if (column.unique) { self.unique = column.id; } }); Ox.extend(self, { columnPositions: [], defaultColumnWidths: self.options.columns.map(function(column) { return column.defaultWidth || column.width; }), itemHeight: 16, page: 0, pageLength: 100, scrollLeft: 0, selectedColumn: getColumnIndexById(self.options.sort[0].key), visibleColumns: self.options.columns.filter(function(column) { return column.visible; }) }); // fixme: there might be a better way than passing both visible and position self.options.columns.forEach(function(column) { if (!Ox.isUndefined(column.position)) { self.visibleColumns[column.position] = column; } }); Ox.extend(self, { columnWidths: self.visibleColumns.map(function(column) { return column.width; }), pageHeight: self.options.pageLength * self.itemHeight }); self.format = {}; self.options.columns.forEach(function(column) { if (column.format) { self.format[column.id] = column.format; } }); // Head if (self.options.columnsVisible) { that.$bar = Ox.Bar({ orientation: 'horizontal', size: 16 }).appendTo(that); that.$head = Ox.Container() .addClass('OxHead') .css({ right: self.options.scrollbarVisible ? Ox.UI.SCROLLBAR_SIZE + 'px' : 0 }) .appendTo(that.$bar); that.$head.$content.addClass('OxTitles'); constructHead(); if (self.options.columnsRemovable) { that.$select = Ox.Select({ id: self.options.id + 'SelectColumns', items: self.options.columns.map(function(column) { return { checked: column.visible, disabled: column.removable === false, id: column.id, title: column.title }; }), max: -1, min: 1, type: 'image' }) .bindEvent('change', changeColumns) .appendTo(that.$bar.$element); } } // Body that.$body = Ox.List({ construct: constructItem, draggable: self.options.draggable, id: self.options.id, itemHeight: 16, items: self.options.items, itemWidth: getItemWidth(), format: self.format, // fixme: not needed, happens in TextList keys: Ox.merge(self.visibleColumns.map(function(column) { return column.id; }), self.options.keys), max: self.options.max, min: self.options.min, pageLength: self.options.pageLength, paste: self.options.paste, orientation: 'vertical', selected: self.options.selected, sort: self.options.sort, sortable: self.options.sortable, type: 'text', unique: self.unique }, Ox.clone(self)) // pass event handler .addClass('OxBody') .css({ top: (self.options.columnsVisible ? 16 : 0) + 'px', overflowY: (self.options.scrollbarVisible ? 'scroll' : 'hidden') }) .scroll(function() { var scrollLeft = $(this).scrollLeft(); if (scrollLeft != self.scrollLeft) { self.scrollLeft = scrollLeft; that.$head && that.$head.scrollLeft(scrollLeft); } }) .bindEvent({ cancel: function(data) { Ox.print('cancel edit', data); }, edit: function(data) { that.editCell(data.id, data.key); }, init: function(data) { // fixme: why does this never reach? //Ox.print('INIT????') //that.triggerEvent('init', data); }, select: function() { self.options.selected = that.$body.options('selected'); } }) .appendTo(that); that.$body.$content.css({ width: getItemWidth() + 'px' }); //Ox.print('s.vC', self.visibleColumns) function addColumn(id) { //Ox.print('addColumn', id); var column, ids, index = 0; Ox.forEach(self.options.columns, function(v) { if (v.visible) { index++; } else if (v.id == id) { column = v; return false; } }); column.visible = true; self.visibleColumns.splice(index, 0, column); self.columnWidths.splice(index, 0, column.width); that.$head.$content.empty(); constructHead(); that.$body.options({ keys: Ox.merge(self.visibleColumns.map(function(column) { return column.id; }), self.options.keys) }); that.$body.reloadPages(); } function changeColumns(data) { var add, ids = []; Ox.forEach(data.selected, function(column) { var index = getColumnIndexById(column.id); if (!self.options.columns[index].visible) { addColumn(column.id); add = true; return false; } ids.push(column.id); }); if (!add) { Ox.forEach(self.visibleColumns, function(column) { if (ids.indexOf(column.id) == -1) { removeColumn(column.id); return false; } }); } triggerColumnChangeEvent(); } function clickColumn(id) { Ox.print('clickColumn', id); var i = getColumnIndexById(id), isSelected = self.options.sort[0].key == self.options.columns[i].id; self.options.sort = [{ key: self.options.columns[i].id, operator: isSelected ? (self.options.sort[0].operator == '+' ? '-' : '+') : self.options.columns[i].operator, map: self.options.columns[i].map }] updateColumn(); // fixme: strangely, sorting the list blocks updating the column, // so we use a timeout for now setTimeout(function() { that.$body.options({sort: self.options.sort}); }, 10); that.triggerEvent('sort', { key: self.options.sort[0].key, operator: self.options.sort[0].operator }); } function constructHead() { self.$heads = []; self.$titles = []; self.$orderButtons = []; self.visibleColumns.forEach(function(column, i) { var $resize; self.$heads[i] = Ox.Element() .addClass('OxHeadCell OxColumn' + Ox.toTitleCase(column.id)) .css({width: self.columnWidths[i] - 5 + 'px'}) .appendTo(that.$head.$content.$element); // if sort operator is set, bind click event if (column.operator) { self.$heads[i].bindEvent({ anyclick: function() { clickColumn(column.id); } }); } // if columns are movable, bind drag events if (self.options.columnsMovable) { self.$heads[i].bindEvent({ dragstart: function(data) { dragstartColumn(column.id, data); }, drag: function(data) { dragColumn(column.id, data); }, dragpause: function(data) { dragpauseColumn(column.id, data); }, dragend: function(data) { dragendColumn(column.id, data); } }) } self.$titles[i] = Ox.Element() .addClass('OxTitle') .css({ width: self.columnWidths[i] - 9 + 'px', textAlign: column.align }) .html(column.title) .appendTo(self.$heads[i]); self.$orderButtons[i] = Ox.Button({ style: 'symbol', title: column.operator == '+' ? 'up' : 'down', type: 'image' }) .addClass('OxOrder') .css({marginTop: (column.operator == '+' ? 1 : -1) + 'px'}) .click(function() { $(this).parent().trigger('click'); }) .appendTo(self.$heads[i]); $resize = Ox.Element() .addClass('OxResize') .appendTo(that.$head.$content.$element); $('
').appendTo($resize); $('
').addClass('OxCenter').appendTo($resize); $('
').appendTo($resize); // if columns are resizable, bind click and drag events if (self.options.columnsResizable) { $resize.addClass('OxResizable') .bindEvent({ doubleclick: function(data) { resetColumn(column.id, data); }, dragstart: function(data) { dragstartResize(column.id, data); }, drag: function(data) { dragResize(column.id, data); }, dragend: function(data) { dragendResize(column.id, data); } }); } }); that.$head.$content.css({ width: (Ox.sum(self.columnWidths) + 2) + 'px' }); if (getColumnPositionById(self.options.columns[self.selectedColumn].id) > -1) { // fixme: save in var toggleSelected(self.options.columns[self.selectedColumn].id); self.$titles[getColumnPositionById(self.options.columns[self.selectedColumn].id)].css({ width: (self.options.columns[self.selectedColumn].width - 25) + 'px' }); } } function constructItem(data) { var $item = $('
') .addClass('OxTarget') .css({ width: getItemWidth(true) + 'px' }); self.visibleColumns.forEach(function(v, i) { var clickable = Ox.isBoolean(v.clickable) ? v.clickable : v.clickable(data), editable = Ox.isBoolean(v.editable) ? v.editable : v.editable(data), $cell; if (v.tooltip) { $cell = Ox.Element({ tooltip: function() { return self.options.selected.indexOf(data[self.unique]) > -1 ? (Ox.isString(v.tooltip) ? v.tooltip : v.tooltip(data)) : ''; } }); } else { // this is faster $cell = $('
'); } $cell.addClass( 'OxCell OxColumn' + Ox.toTitleCase(v.id) + (clickable ? ' OxClickable' : '') + (editable ? ' OxEditable' : '') ) .css({ width: (self.columnWidths[i] - (self.options.columnsVisible ? 9 : 8)) + 'px', borderRightWidth: (self.options.columnsVisible ? 1 : 0) + 'px', textAlign: v.align }) // if the column id is not in data, we're constructing an empty cell .html(v.id in data ? formatValue(v.id, data[v.id], data) : '') .appendTo($item); }); return $item; } function dragstartColumn(id, e) { self.drag = { columnOffsets: getColumnOffsets(), listOffset: that.$element.offset().left - that.$body.scrollLeft(), startPos: getColumnPositionById(id) } self.drag.stopPos = self.drag.startPos; $('.OxColumn' + Ox.toTitleCase(id)).css({opacity: 0.25}); self.drag.startPos > 0 && self.$heads[self.drag.startPos].prev().children().eq(2).css({opacity: 0.25}); self.$heads[self.drag.startPos].next().children().eq(0).css({opacity: 0.25}); self.$heads[self.drag.startPos].addClass('OxDrag').css({ // fixme: why does the class not work? cursor: 'move' }); } function dragColumn(id, e) { var listLeft = that.$element.offset().left, listRight = listLeft + that.$element.width(), pos = self.drag.stopPos; Ox.forEach(self.drag.columnOffsets, function(offset, i) { var x = self.drag.listOffset + offset + self.columnWidths[i] / 2; if (i < self.drag.startPos && e.clientX < x) { self.drag.stopPos = i; return false; } else if (i > self.drag.startPos && e.clientX > x) { self.drag.stopPos = i; } }); if (self.drag.stopPos != pos) { moveColumn(id, self.drag.stopPos); self.drag.columnOffsets = getColumnOffsets(); self.drag.startPos = self.drag.stopPos; ///* var left = self.drag.columnOffsets[self.drag.startPos], right = left + self.columnWidths[self.drag.startPos]; if (left < that.$body.scrollLeft() || right > that.$element.width()) { that.$body.scrollLeft( left < that.$body.scrollLeft() ? left : right - that.$element.width() ); self.drag.listOffset = that.$element.offset().left - that.$body.scrollLeft(); } //*/ } if (e.clientX < listLeft + 16 || e.clientX > listRight - 16) { if (!self.scrollInterval) { self.scrollInterval = setInterval(function() { that.$body.scrollLeft( that.$body.scrollLeft() + (e.clientX < listLeft + 16 ? -16 : 16) ); self.drag.listOffset = that.$element.offset().left - that.$body.scrollLeft(); }, 100); } } else if (self.scrollInterval) { clearInterval(self.scrollInterval); self.scrollInterval = 0; } } function dragpauseColumn(id, e) { } function dragendColumn(id, e) { var column = self.visibleColumns.splice(self.drag.stopPos, 1)[0], width = self.columnWidths.splice(self.drag.stopPos, 1)[0]; self.visibleColumns.splice(self.drag.stopPos, 0, column); self.columnWidths.splice(self.drag.stopPos, 0, width); that.$head.$content.empty(); constructHead(); $('.OxColumn' + Ox.toTitleCase(id)).css({opacity: 1}); self.$heads[self.drag.stopPos].removeClass('OxDrag').css({ cursor: 'pointer' }); that.$body.clearCache(); triggerColumnChangeEvent(); } function dragstartResize(id, e) { var pos = getColumnPositionById(id); self.drag = { startWidth: self.columnWidths[pos] }; } function dragResize(id, e) { var width = Ox.limit( self.drag.startWidth + e.clientDX, self.options.columnWidth[0], self.options.columnWidth[1] ); resizeColumn(id, width); } function dragendResize(id, e) { var pos = getColumnPositionById(id); // fixme: shouldn't this be resizecolumn? that.triggerEvent('columnresize', { id: id, width: self.columnWidths[pos] }); } function find(data) { // fixme: works only if items are an array var query = data.keys, sort = self.options.sort[0]; Ox.print('QUERY', query) Ox.forEach(self.options.items, function(item, i) { var value = ( sort.map ? sort.map(item[sort.key]) : item[sort.key] ).toString().toLowerCase(); if (Ox.startsWith(value, query)) { that.$body.options({selected: [item[self.unique]]}); Ox.print('QUERY', query, 'VALUE', value) return false; } }); } function formatValue(key, value, data) { // fixme: this may be obscure... // since the format of a value may depend on another value, // we pass all data as a second parameter to the supplied format function var format = self.format[key]; if (value === null) { value = ''; } else if (format) { value = Ox.isObject(format) ? Ox['format' + Ox.toTitleCase(format.type)] .apply(this, Ox.merge([value], format.args || [])) : format(value, data); } else if (Ox.isArray(value)) { value = value.join(', '); } return value; } function getCell(id, key) { Ox.print('getCell', id, key) var $item = getItem(id); key = key || ''; return $($item.find('.OxCell.OxColumn' + Ox.toTitleCase(key))[0]); } function getColumnOffsets() { return self.visibleColumns.map(function(column, i) { return Ox.sum(self.visibleColumns.map(function(column_, i_) { return i_ < i ? self.columnWidths[i_] : 0; })); }); } function getColumnIndexById(id) { return Ox.getPositionById(self.options.columns, id); } function getColumnPositionById(id) { return Ox.getPositionById(self.visibleColumns, id); } function getItem(id) { //Ox.print('getItem', id) var $item = null; that.find('.OxItem').each(function() { $this = $(this); if ($this.data('id') == id) { $item = $this; return false; } }); return $item; } function getItemWidth(cached) { // fixme: this gets called for every constructItem and is slooow // the proper way to fix this would be to find out how and when // that.$element.width() might change... which would probably // mean binding to every SplitPanel and window resize... // for now, use a cached value if (!cached) { self.cachedWidth = that.$element.width(); } else if (!self.cachedWidth || self.cachedWidthTime < +new Date() - 5000) { self.cachedWidth = that.$element.width(); self.cachedWidthTime = +new Date(); } return Math.max( Ox.sum(self.columnWidths), self.cachedWidth - (self.options.scrollbarVisible ? Ox.UI.SCROLLBAR_SIZE : 0) ); } function moveColumn(id, pos) { //Ox.print('moveColumn', id, pos) var startPos = getColumnPositionById(id), stopPos = pos, startSelector = '.OxColumn' + Ox.toTitleCase(id), stopSelector = '.OxColumn' + Ox.toTitleCase(self.visibleColumns[stopPos].id), insert = startPos < stopPos ? 'insertAfter' : 'insertBefore' $column = $('.OxHeadCell' + startSelector), $resize = $column.next(); //Ox.print(startSelector, insert, stopSelector) $column.detach()[insert](insert == 'insertAfter' ? $('.OxHeadCell' + stopSelector).next() : $('.OxHeadCell' + stopSelector)); $resize.detach().insertAfter($column); that.$body.find('.OxItem').each(function() { var $this = $(this); $this.children(startSelector).detach()[insert]( $this.children(stopSelector) ); }); var $head = self.$heads.splice(startPos, 1)[0], columnWidth = self.columnWidths.splice(startPos, 1)[0], visibleColumn = self.visibleColumns.splice(startPos, 1)[0]; self.$heads.splice(stopPos, 0, $head); self.columnWidths.splice(stopPos, 0, columnWidth); self.visibleColumns.splice(stopPos, 0, visibleColumn); var pos = getColumnPositionById(self.options.columns[self.selectedColumn].id); if (pos > -1) { that.$element.find('.OxResize .OxSelected').removeClass('OxSelected'); pos > 0 && self.$heads[pos].prev().children().eq(2).addClass('OxSelected'); self.$heads[pos].next().children().eq(0).addClass('OxSelected'); if (pos == stopPos) { pos > 0 && self.$heads[pos].prev().children().eq(2).css({opacity: 0.25}); self.$heads[pos].next().children().eq(0).css({opacity: 0.25}); } } } function removeColumn(id) { //Ox.print('removeColumn', id); var index = getColumnIndexById(id), itemWidth, position = getColumnPositionById(id), selector = '.OxColumn' + Ox.toTitleCase(id), $column = $('.OxHeadCell ' + selector), $order = $column.next(), $resize = $order.next(); self.options.columns[index].visible = false; self.visibleColumns.splice(position, 1); self.columnWidths.splice(position, 1); that.$head.$content.empty(); constructHead(); itemWidth = getItemWidth(); that.$body.find('.OxItem').each(function() { var $this = $(this); $this.children(selector).remove(); $this.css({width: itemWidth + 'px'}); }); that.$body.$content.css({ width: itemWidth + 'px' }); that.$body.options({ keys: Ox.merge(self.visibleColumns.map(function(column) { return column.id; }), self.options.keys) }); //that.$body.clearCache(); } function resetColumn(id) { var width = self.defaultColumnWidths[getColumnIndexById(id)]; resizeColumn(id, width); that.triggerEvent('columnresize', { id: id, width: width }); } function resizeColumn(id, width) { var i = getColumnIndexById(id), pos = getColumnPositionById(id); self.options.columns[i].width = width; self.columnWidths[pos] = width; if (self.options.columnsVisible) { that.$head.$content.css({ width: (Ox.sum(self.columnWidths) + 2) + 'px' }); self.$heads[pos].css({ width: width - 5 + 'px' }); self.$titles[pos].css({ width: width - 9 - (i == self.selectedColumn ? 16 : 0) + 'px' }); } that.$element.find('.OxCell.OxColumn' + Ox.toTitleCase(self.options.columns[i].id)).css({ width: width - (self.options.columnsVisible ? 9 : 8) + 'px' }); setWidth(); } function setWidth() { var width = getItemWidth(); that.$body.$content.find('.OxItem').css({ // fixme: can we avoid this lookup? width: width + 'px' }); that.$body.$content.css({ width: width + 'px' // fixme: check if scrollbar visible, and listen to resize/toggle event }); } function toggleSelected(id) { var pos = getColumnPositionById(id); if (pos > -1) { updateOrder(id); pos > 0 && self.$heads[pos].prev().children().eq(2).toggleClass('OxSelected'); self.$heads[pos].toggleClass('OxSelected'); self.$heads[pos].next().children().eq(0).toggleClass('OxSelected'); self.$titles[pos].css({ width: self.$titles[pos].width() + (self.$heads[pos].hasClass('OxSelected') ? -16 : 16) + 'px' }); } } function triggerColumnChangeEvent() { that.triggerEvent('columnchange', { ids: self.visibleColumns.map(function(column) { return column.id; }) }); } function updateColumn() { var columnId = self.options.columns[self.selectedColumn].id isSelected = columnId == self.options.sort[0].key; if (self.options.columnsVisible) { if (isSelected) { updateOrder(columnId); } else { toggleSelected(columnId); self.selectedColumn = getColumnIndexById(self.options.sort[0].key); toggleSelected(self.options.columns[self.selectedColumn].id); } } } function updateOrder(id) { var operator = self.options.sort[0].operator, pos = getColumnPositionById(id); if (pos > -1) { self.$orderButtons[pos].options({ title: operator == '+' ? 'up' : 'down' }).css({ marginTop: (operator == '+' ? 1 : -1) + 'px' }); } } self.setOption = function(key, value) { //Ox.print('---------------------------- TextList setOption', key, value) if (key == 'items') { that.$body.options(key, value); } else if (key == 'paste') { that.$body.options(key, value); } else if (key == 'selected') { that.$body.options(key, value); } else if (key == 'sort') { updateColumn(); that.$body.options(key, value); } }; that.closePreview = function() { that.$body.closePreview(); return that; }; that.addItem = function(item) { /* self.options.items.push(item); that.$body.options({items: self.options.items}); //that.$body.options({selected: [item.id]}); */ } that.editCell = function(id, key) { Ox.print('editCell', id, key) var $item = getItem(id), $cell = getCell(id, key), $input, html = $cell.html(), index = getColumnIndexById(key), column = self.options.columns[index], width = column.width - self.options.columnsVisible; $cell.empty() .addClass('OxEdit') .css({width: width + 'px'}); $input = Ox.Input({ autovalidate: column.input ? column.input.autovalidate : null, style: 'square', value: html, width: width }) .bind({ mousedown: function(e) { // keep mousedown from reaching list e.stopPropagation(); } }) .bindEvent({ blur: submit, }) .appendTo($cell); //.focusInput(); setTimeout($input.focusInput, 0); // fixme: strange function submit() { var value = $input.value(); //$input.loseFocus().remove(); // fixme: leaky, inputs remain in focus stack $cell.removeClass('OxEdit') .css({ // account for padding width: (width - 8) + 'px' }) .html(value); that.triggerEvent('submit', { id: id, key: key, value: value }); } } that.gainFocus = function() { that.$body.gainFocus(); return that; }; that.loseFocus = function() { that.$body.loseFocus(); return that; } that.paste = function(data) { that.$body.paste(); return that; }; that.reloadList = function(stayAtPosition) { that.$body.reloadList(stayAtPosition); return that; }; that.resizeColumn = function(id, width) { resizeColumn(id, width); return that; } that.size = function() { Ox.print('SIZE FUNCTION CALLED') setWidth(); that.$body.size(); } // fixme: deprecated that.sortList = function(key, operator) { Ox.print('$$$$ DEPRECATED $$$$') var isSelected = key == self.options.sort[0].key; self.options.sort = [{ key: key, operator: operator, map: self.options.columns[self.selectedColumn].sort }]; if (self.options.columnsVisible) { if (isSelected) { updateOrder(self.options.columns[self.selectedColumn].id); } else { toggleSelected(self.options.columns[self.selectedColumn].id); self.selectedColumn = getColumnIndexById(key); toggleSelected(self.options.columns[self.selectedColumn].id); } } // fixme: strangely, sorting the list blocks toggling the selection, // so we use a timeout for now setTimeout(function() { that.$body.options({sort: self.options.sort}); /* that.$body.sortList( self.options.sort[0].key, self.options.sort[0].operator, self.options.sort[0].map ); */ }, 10); return that; }; that.value = function(id, key, value) { // fixme: make this accept id, {k: v, ...} //Ox.print('value', id, key, value) var $cell, $item = getItem(id); //column = self.options.columns[getColumnIndexById(key)]; if (arguments.length == 1) { return that.$body.value(id); } else if (arguments.length == 2) { return that.$body.value(id, key); } else { that.$body.value(id, key, value); if (key == self.unique) { // unique id has changed self.options.selected = self.options.selected.map(function(id_) { return id_ == id ? value : id_ }); id = value; } $cell = getCell(id, key); $cell && $cell.html(formatValue(key, value)); if (key == self.options.sort[0].key) { // sort key has changed that.$body.sort(); } return that; } } return that; };