/* * HGrid - v0.2.10 * A Javascript-based hierarchical grid that can be used to manage and organize files and folders */ (function (global, factory) { if (typeof define === 'function' && define.amd) { // AMD/RequireJS define(['jquery'], factory); } else if (typeof module === 'object') { // CommonJS/Node module.exports = factory(jQuery); } else { // No module system global.HGrid = factory(jQuery); } }(this, function(jQuery) { /** * Provides the main HGrid class and HGrid.Error. * @module HGrid */ this.HGrid = (function($) { 'use strict'; var DEFAULT_INDENT = 20; var ROOT_ID = 'root'; var ITEM = 'item'; var FOLDER = 'folder'; function noop() {} ///////////////////// // Data Structures // ///////////////////// var idCounter = 0; // Ensure unique IDs among trees and leaves function getUID() { return idCounter++; } /** * A tree node. If constructed with no args, the node is * considered a root, * * ``` * var root = new HGrid.Tree(); * root.depth // => 0 * var subtree = new Tree({name: 'A subtree', kind: 'folder'}); * root.add(subtree); * subtree.depth // => 1 * ``` * * @class HGrid.Tree * @constructor * @param {Object} data Data to attach to the tree */ function Tree(data) { if (data === undefined) { // No args passed, it's a root this.data = {}; this.id = ROOT_ID; /** * @attribute depth * @type {Number} */ this.depth = 0; this.dataView = new Slick.Data.DataView({ inlineFilters: true }); } else { this.data = data; this.id = data.id ? data.id : getUID(); // Depth and dataView will be set by parent after being added as a subtree this.depth = null; this.dataView = null; } this.children = []; this.parentID = null; } /** * Construct a new Tree from either an object or an array of data. * * Example input: * ``` * [{name: 'Documents', kind: 'folder', * children: [{name: 'mydoc.txt', type: 'item'}]}, * {name: 'rootfile.txt', kind: 'item'} * ] * ``` * * @method fromObject * @param {Object} data * @param {parent} [parent] Parent item. * */ Tree.fromObject = function(data, parent, args) { args = args || {}; var tree, children, leaf, subtree; // If data is an array, create a new root if (Array.isArray(data)) { tree = new Tree(); children = data; } else { // data is an object, create a subtree children = data.children || []; tree = new Tree(data); tree.depth = parent.depth + 1; tree.dataView = parent.dataView; if (args.collapse) { // TODO: Hardcoded. Change this when _collapsed and _hidden states // are saved on Tree and Leaf objects, and not just on the dataview items tree.data._collapsed = true; } } // Assumes nodes have a `kind` property. If `kind` is "item", create a leaf, // else create a Tree. for (var i = 0, len = children.length; i < len; i++) { var child = children[i]; if (child.kind === ITEM) { leaf = Leaf.fromObject(child, tree, args); tree.add(leaf); } else { subtree = Tree.fromObject(child, tree, args); tree.add(subtree); } } return tree; }; Tree.resetIDCounter = function() { idCounter = 0; }; Tree._getCurrentID = function() { return idCounter; }; /** * Add a component to this node * @method add * @param component Either a Tree or Leaf. * @param {Boolean} [updateDataView] Whether to insert the item into the DataView */ Tree.prototype.add = function(component, updateDataView) { // Set deptth, parent ID, and dataview component.parentID = this.id; component.depth = this.depth + 1; component.dataView = this.dataView; this.children.push(component); if (updateDataView) { this.insertIntoDataView(component); } return this; }; Tree.prototype.empty = function(removeSelf) { if (removeSelf) { // Clear children var item = this.getItem(); this.dataView.deleteItem(item.id); } for(var i=0, child; child = this.children[i]; i++) { child.empty(true); child.children = []; } this.children = []; return this; }; // Remove an object from an array, searching by an attribute value function removeByProperty(arr, attr, value){ var i = arr.length; while(i--){ if(arr[i] && arr[i].hasOwnProperty(attr) && (arguments.length > 2 && arr[i][attr] === value )){ arr.splice(i,1); return true; } } return false; } /** * Remove a child node. * @param {Object} child The child node to remove or an id. */ Tree.prototype.remove = function(child) { var childId = typeof child === 'object' ? child.id : child; var removed = removeByProperty(this.children, 'id', child); if(!removed) { for (var i = 0, node; node = this.children[i]; i++) { removed = node.remove(child); } } else { this.dataView.deleteItem(childId); } }; /** * Get the tree's corresponding item object from the dataview. * @method getItem */ Tree.prototype.getItem = function() { return this.dataView.getItemById(this.id); }; /** * Sort the tree in place, on a key. * @method sort */ Tree.prototype.sort = function(key, asc) { this.children.sort(function(child1, child2) { var val1 = child1.data[key], val2 = child2.data[key]; var sign = asc ? 1 : -1; var ret = (val1 === val2 ? 0 : (val1 > val2 ? 1 : -1)) * sign; if (ret !== 0) { return ret; } return 0; }); for (var i = 0, child; child = this.children[i]; i++) { child.sort(key, asc); } return this; }; // TODO: test me Tree.prototype.sortCmp = function(cmp) { this.children.sort(cmp); for (var i = 0, child; child = this.children[i]; i++) { child.sortCmp(key); } return this; }; /** * Computes the index in the DataView where to insert an item, based on * the item's parentID property. * @private */ function computeAddIdx(item, dataView) { var parent = dataView.getItemById(item.parentID); if (parent) { return dataView.getIdxById(parent.id) + 1; } return 0; } Tree.prototype.insertIntoDataView = function(component) { var data = component.toData(); var idx; if (Array.isArray(data)) { for (var i = 0, len = data.length; i < len; i++) { var datum = data[i]; idx = computeAddIdx(datum, this.dataView); this.dataView.insertItem(idx, datum); } } else { // data is an Object, so component is a leaf idx = computeAddIdx(data, this.dataView); this.dataView.insertItem(idx, data); } return this; }; Tree.prototype.ensureDataView = function(dataView) { if (!dataView) { dataView = this.dataView; } this.dataView = dataView; for (var i = 0, node; node = this.children[i]; i++) { node.ensureDataView(dataView); } return this; }; /** * Update the dataview with this tree's data. This should only be called on * a root node. */ Tree.prototype.updateDataView = function(onlySetItems) { if (!this.dataView) { throw new HGrid.Error('Tree does not have a DataView. updateDataView must be called on a root node.'); } if (!onlySetItems) { this.ensureDataView(); } this.dataView.beginUpdate(); this.dataView.setItems(this.toData()); this.dataView.endUpdate(); return this; }; /** * Convert the tree to SlickGrid-compatible data * * @param {Array} result Memoized result. * @return {Array} Array of SlickGrid data */ Tree.prototype.toData = function(result) { // Add this node's data, unless it's a root var data = result || []; if (this.depth !== 0) { var thisItem = $.extend({}, { id: this.id, parentID: this.parentID, _node: this, depth: this.depth }, this.data); data.push(thisItem); } for (var i = 0, len = this.children.length; i < len; i++) { var child = this.children[i]; child.toData(data); } return data; }; /** * Collapse this and all children nodes, by setting the _collapsed attribute * @method collapse * @param {Boolean} hideSelf Whether to hide this node as well */ Tree.prototype.collapse = function(hideSelf, refresh) { var item; if (!this.isRoot()){ item = this.getItem(); // A node can be collapsed but not hidden. For example, if you click // on a folder, it should collapse and hide all of its contents, but the folder // should still be visible. if (hideSelf) { item._hidden = true; } else { item._collapsed = true; item._hidden = false; } } // Collapse and hide all children for (var i = 0, node; node = this.children[i]; i++) { node.collapse(true); } if (!this.isRoot() && refresh) { this.dataView.updateItem(item.id, item); // need to update the item index } return this; }; /** * Performs breadth-first traversal of the tree, executing a function once * per node. * @method bfTraverse * @param {Function} fun Function to execute for each node * @param {Number} maxDepth Max depth to traverse to, or null. */ Tree.prototype.bfTraverse = function(fun, maxDepth) { var frontier = new Queue(); var next = this; while (next) { if (maxDepth && next.depth > maxDepth) { break; } fun.call(this, next); if (next.children.length) { // enqueue all children for (var i = 0, child; child = next.children[i]; i++){ frontier.enq(child); } } next = frontier.deq(); } return this; }; /** * Collapse all nodes at a certain depth * @method collapseAt * @param {Number} depth The depth to collapse at * @param {Boolean} refresh Whether to refresh the DataView. */ Tree.prototype.collapseAt = function(depth, refresh) { if (depth === 0) { return this.collapse(false, refresh); } this.bfTraverse(function(node) { if (node.depth === depth && node instanceof Tree) { // only collapse trees on the way node.collapse(false, true); // Make sure item is updated } }, depth); if (refresh) { this.dataView.refresh(); } return this; }; Tree.prototype.expandAt = function(depth, refresh) { if (depth === 0) { return this.expand(false, refresh); } this.bfTraverse(function(node) { if (!node.isRoot() && node.depth < depth) { node.expand(false, true); // Make sure item is updated } }, depth); if (refresh) { this.dataView.refresh(); } return this; }; Tree.prototype.isHidden = function() { return this.getItem()._hidden; }; /** * Expand this and all children nodes by setting the item's _collapsed attribute * @method expand */ Tree.prototype.expand = function(notFirst, refresh) { var item; if (!this.isRoot()){ item = this.getItem(); if (!notFirst) { item._collapsed = false; } item._hidden = false; } // Expand all children for (var i = 0, node; node = this.children[i]; i++) { if (!item._collapsed) { // Maintain subtree's collapsed state node.expand(true); } } if (!this.isRoot() && refresh) { this.dataView.updateItem(item.id, item); } return this; }; Tree.prototype.isRoot = function() { return this.depth === 0; }; /** * @method isCollapsed * @return {Boolean} Whether the node is collapsed. */ Tree.prototype.isCollapsed = function() { return Boolean(this.getItem()._collapsed); }; /** * @method getPathToRoot * @param {Array} pathSoFar IDs of any path being passed in. Used by leafs. * @return {Array} Node IDs from current to root * * */ Tree.prototype.getPathToRoot = function(pathSoFar) { var path = []; if(typeof pathSoFar !== 'undefined' && pathSoFar instanceof Array){ path = pathSoFar; } var self = this; var item = self.getItem(); do { path.push(item.id); item = self.dataView.getItemById(item.parentID); } while (typeof item !== 'undefined' && item.parentID !== null); return path; }; /** * Leaf representation * @class HGrid.Leaf * @constructor */ function Leaf(data) { this.data = data; this.id = data.id ? data.id : getUID(); this.parentID = null; // Set by parent this.depth = null; this.children = []; this.dataView = null; // Set by parent } /** * Construct a new Leaf from an object. * @method fromObject * @param obj * @static * @return {Leaf} The constructed Leaf. */ Leaf.fromObject = function(obj, parent, args) { args = args || {}; var leaf = new Leaf(obj); if (parent) { leaf.depth = parent.depth + 1; leaf.parentID = parent.id; leaf.dataView = parent.dataView; } if (args.collapse) { // TODO: Hardcoded. Change this when _collapsed and _hidden states // are saved on Tree and Leaf objects, and not just on the dataview items leaf.data._collapsed = true; } return leaf; }; /** * @method getPathToRoot * @return {Array} path of the leaf item to the root. */ Leaf.prototype.getPathToRoot = function() { var parentID = this.parentID; if(parentID === 'root'){ return [this.id]; }else { var parent = this.dataView.getItemById(this.parentID)._node; return parent.getPathToRoot([this.id]); } }; /** * Get the leaf's corresponding item from the dataview. * @method getItem */ Leaf.prototype.getItem = function() { return this.dataView.getItemById(this.id); }; /** * Collapse this leaf by setting its item's _collapsed property. * @method collapse */ /*jshint unused: false */ Leaf.prototype.collapse = function(hideSelf, refresh) { var item = this.getItem(); item._collapsed = item._hidden = true; return this; }; /** * Expand this leaf by setting its item's _collapse property * @method expand */ Leaf.prototype.expand = function() { var item = this.getItem(); item._collapsed = item._hidden = false; return this; }; Leaf.prototype.remove = noop; /** * Convert the Leaf to SlickGrid data format * @method toData * @param {Array} [result] The memoized result * @return {Object} The leaf an item object. */ Leaf.prototype.toData = function(result) { var item = $.extend({}, { id: this.id, parentID: this.parentID, _node: this, depth: this.depth }, this.data); if (result) { result.push(item); } return item; }; Leaf.prototype.ensureDataView = function(dataView) { if (!dataView) { dataView = this.dataView; } this.dataView = dataView; return this; }; Leaf.prototype.sort = noop; Leaf.prototype.isRoot = function() { return this.depth === 0; }; Leaf.prototype.empty = function() { var item = this.getItem(); this.dataView.deleteItem(item.id); return this; }; // An efficient, lightweight queue implementation, adapted from Queue.js by Steven Morley function Queue() { this.queue = []; this.offset = 0; } Queue.prototype.enq = function(item) { this.queue.push(item); }; Queue.prototype.deq = function() { if (this.queue.length === 0) { return undefined; } // store item at front of queue var item = this.queue[this.offset]; if (++ this.offset * 2 >= this.queue.length) { this.queue = this.queue.slice(this.offset); this.offset = 0; } return item; }; Queue.prototype.isEmpty = function() { return this.queue.length === 0; }; //////////////// // Formatting // //////////////// /** * Sanitize a value to be displayed as HTML. */ function sanitized(value) { return value.replace(/&/g, '&').replace(//g, '>'); } /** * Render a spacer element given an indent value in pixels. */ function makeIndentElem(indent) { return ''; } /** * Adds a span element that indents an item element, given an item. * `item` must have a depth property. * @param {Object} item * @param {String} html The inner HTML * @return {String} The rendered HTML */ function withIndent(item, html, indentWidth) { indentWidth = indentWidth || DEFAULT_INDENT; var indent = (item.depth - 1) * indentWidth; // indenting span var spacer = makeIndentElem(indent); return spacer + html; } /** * Surrounds HTML with a span with class='hg-item-content' and 'data-id' attribute * equal to the item's id * @param {Object} item The item object * @param {string} html The inner HTML * @return {String} The rendered HTML */ function asName(item, html) { var cssClass = item.kind === FOLDER ? HGrid.Html.folderNameClass : HGrid.Html.itemNameClass; var openTag = ''; var closingTag = ''; return [openTag, html, closingTag].join(''); } /** * Render the html for a button, given an item and buttonDef. buttonDef is an * object of the form {text: "My button", cssClass: "btn btn-primary", * action: "download" }} * @class renderButton * @private */ function renderButton(buttonDef) { var cssClass; var tag = buttonDef.tag || 'button'; // For now, buttons are required to have the hg-btn class so that a click // event listener can be attacked to them later if (buttonDef.cssClass) { cssClass = HGrid.Html.buttonClass + ' ' + buttonDef.cssClass; } else { cssClass = HGrid.Html.buttonClass; } var action = buttonDef.action || 'noop'; var attributes = buttonDef.attributes || ''; var data = { action: action, cssClass: cssClass, tag: tag, text: buttonDef.text, attributes: attributes }; var html = tpl('<{{tag}} data-hg-action="{{action}}" ' + 'class="{{cssClass}}" {{attributes}} >{{text}}{{tag}}>', data); return html; } function renderButtons(buttonDefs) { var renderedButtons = buttonDefs.map(function(btn) { var html = renderButton(btn); return html; }).join(''); return renderedButtons; } function isLoading(item) { return item._node._load_status === HGrid.LOADING_STARTED; } /** * Microtemplating function. Adapted from Riot.js (MIT License). */ var tpl_fn_cache = {}; var tpl = function(template, data) { /*jshint quotmark:false */ if (!template) { return ''; } tpl_fn_cache[template] = tpl_fn_cache[template] || new Function("_", "return '" + template .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/'/g, "\\'") .replace(/\{\{\s*(\w+)\s*\}\}/g, "'+(_.$1?(_.$1+''):(_.$1===0?0:''))+'") + "'" ); return tpl_fn_cache[template](data); }; HGrid.Html = { // Expand/collapse button expandElem: '', collapseElem: '', // Icons folderIcon: '', fileIcon: '', // Placeholder for error messages. Upload error messages will be interpolated here errorElem: ' ', // CSS Classes buttonClass: 'hg-btn', nameClass: 'hg-name', folderNameClass: 'hg-folder-name', itemNameClass: 'hg-item-name', toggleClass: 'hg-toggle' }; HGrid.HtmlFactories = { // Expand/collapse button expandElem: function(item) { return isLoading(item) ? '' : ''; }, collapseElem: function(item) { return isLoading(item) ? '' : ''; }, }; /////////// // HGrid // /////////// // Formatting helpers public interface HGrid.Fmt = HGrid.Format = { withIndent: withIndent, asName: asName, makeIndentElem: makeIndentElem, sanitized: sanitized, button: renderButton, buttons: renderButtons, tpl: tpl }; // Predefined actions HGrid.Actions = { download: { on: 'click', callback: function(evt, item) { this.options.onClickDownload.call(this, evt, item); } }, // Must quote this since "delete" is a reserved word 'delete': { on: 'click', callback: function(evt, item) { this.options.onClickDelete.call(this, evt, item); } }, upload: { on: 'click', callback: function(evt, item) { this.options.onClickUpload.call(this, evt, item); } }, noop: { on: 'click', callback: noop } }; // Predefined column schemas HGrid.Col = HGrid.Columns = { // Name field schema Name: { id: 'name', name: 'Name', sortkey: 'name', folderView: HGrid.Html.folderIcon + ' {{name}}', itemView: HGrid.Html.fileIcon + ' {{name}}', sortable: true, indent: DEFAULT_INDENT, isName: true, showExpander: function(item, args) { return item.kind === HGrid.FOLDER && (item._node.children.length && item.depth || args.lazyLoad) && !item._processing; } }, // Actions buttons schema ActionButtons: { id: 'actions', name: 'Actions', width: 50, sortable: false, folderView: function() { var buttonDefs = []; if (this.options.uploads) { buttonDefs.push({ text: 'Upload', action: 'upload' }); } if (buttonDefs) { return renderButtons(buttonDefs); } return ''; }, itemView: function() { var buttonDefs = [{ text: 'Download', action: 'download' }, { text: 'Delete', action: 'delete' }]; return renderButtons(buttonDefs); } } }; /** * Default options object * @class defaults */ var defaults = { /** * The data for the grid. * @property data */ data: null, /** * Options passed to jQuery.ajax on every request for additional data. * @property [ajaxOptions] * @type {Object} */ ajaxOptions: {}, /** * Returns the URL where to fetch the contents for a given folder. Enables * lazy-loading of data. * @param {Object} folder The folder data item. * @property {Function} [fetchUrl] */ fetchUrl: null, fetchSuccess: function(data, item) {}, fetchError: function(error, item) {}, fetchStart: function(item) {}, /** * Enable uploads (requires DropZone) * @property [uploads] * @type {Boolean} */ uploads: false, /** * Array of column schemas * @property [columns] */ columns: [HGrid.Columns.Name], /** * @property [width] Width of the grid */ width: 600, /** * Height of the grid div in px or 'auto' (to disable vertical scrolling).* * @property [height] */ height: 300, /** * CSS class applied for a highlighted row. * @property [highlightClass] * @type {String} */ highlightClass: 'hg-row-highlight', /** * Width to indent items (in px)* * @property indent */ indent: DEFAULT_INDENT, /** * Additional options passed to Slick.Grid constructor * See: https://github.com/mleibman/SlickGrid/wiki/Grid-Options * @property [slickgridOptions] */ slickgridOptions: {}, /** * URL to send upload requests to. Can be either a string of a function * that receives a data item. * Example: * uploadUrl: function(item) {return '/upload/' + item.id; } * @property [uploadUrl] */ uploadUrl: null, /** * Array of accepted file types. Can be file extensions or mimetypes. * Example: `['.py', 'application/pdf', 'image/*'] * @property [acceptedFiles] * @type {Array} */ acceptedFiles: null, /** * Max filesize in Mb. * @property [maxFilesize] */ maxFilesize: 256, /** * HTTP method to use for uploading. * Can be either a string or a function that receives the item * to upload to and returns the method name. */ uploadMethod: 'POST', /** * Additional headers to send with upload requests. */ uploadHeaders: {}, /** * Additional options passed to DropZone constructor * See: http://www.dropzonejs.com/ * @property [dropzoneOptions] * @type {Object} */ dropzoneOptions: {}, /** * Callback function executed after an item is clicked. * By default, expand or collapse the item. * @property [onClick] */ /*jshint unused: false */ onClick: function(event, item) {}, onClickDownload: function(event, item, options) { this.downloadItem(item, options); }, onClickDelete: function(event, item, options) { this.deleteFile(item, options); }, onClickUpload: function(event, item, options) { // Open up a filepicker for the folder this.uploadToFolder(item); }, onExpand: function(event, item) {}, onCollapse: function(event, item) {}, /** * Callback executed after an item is added. * @property [onItemAdded] */ onItemAdded: function(item) {}, // Dragging related callbacks onDragover: function(evt, item) {}, onDragenter: function(evt, item) {}, onDragleave: function(evt, item) {}, onDrop: function(event, item) {}, /** * Called when a column is sorted. * @param {Object} event * @param {Object} column The column definition for the sorted column. * @param {Object} args SlickGrid sorting args. */ onSort: function(event, column, args) {}, /** * Called whenever a file is added for uploaded * @param {Object} file The file object. Has gridElement and gridItem bound to it. * @param {Object} item The added item * @param {Object} folder The folder item being uploaded to */ uploadAdded: function(file, item, folder) {}, /** * Called whenever a file gets processed. * @property {Function} [uploadProcessing] */ /*jshint unused: false */ uploadProcessing: function(file, item) { // TODO: display Cancel upload button text? }, /** * Called whenever an upload error occurs * @property [uploadError] * @param {Object} file The HTML file object * @param {String} message Error message * @param {Object} item The placeholder item that was added to the grid for the file. */ /*jshint unused: false */ uploadError: function(file, message, item, folder) { // The row element for the added file is stored on the file object var $rowElem = $(file.gridElement); var msg; if (typeof message !== 'string' && message.error) { msg = message.error; } else { msg = message; } // Show error message in any element within the row // that contains 'data-upload-errormessage' $rowElem.find('[data-upload-errormessage]').each(function(i) { this.textContent = msg; }); return this; }, /** * Called whenever upload progress gets updated. * @property [uploadProgress] * @param {Object} file the file object * @param {Number} progress Percentage (0-100) * @param {Number} bytesSent * @param {The data item element} item */ /*jshint unused: false */ uploadProgress: function(file, progress, bytesSent, item) { // Use the row as a progress bar var $row = $(file.gridElement); $row.width(progress + '%'); }, /** * Called whenever an upload is finished successfully * @property [uploadSuccess] */ /*jshint unused: false */ uploadSuccess: function(file, item, data) {}, /** * Called when an upload completes (whether it is successful or not) * @property [uploadComplete] */ uploadComplete: function(file, item) {}, /** * Called before a file gets uploaded. If `done` is called with a string argument, * An error is thrown with the message. If `done` is called with no arguments, * the file is accepted. * @property [uploadAccept] * @param {Object} file The file object * @param {Object} folder The folder item being uploaded to * @param {Function} done Called to either accept or reject a file. */ uploadAccept: function(file, folder, done) { return done(); }, /** * Called just before an upload request is sent. * @property [uploadSending] */ uploadSending: function(file, item, xhr, formData) {}, /** * Returns the url where to download and item * @param {Object} row The row object * @return {String} The download url */ downloadUrl: function(item) {}, deleteUrl: function(item) {}, deleteMethod: function(item) {}, listeners: [], /** * Additional initialization. Useful for adding listeners. * @property {Function} init */ init: function() {}, // CSS Selector for search input box searchInput: null, /** * Search filter that returns true if an item should be displayed in the grid. * By default, items will be searched by name (case insensitive). * @param {Object} item A data item * @param {String} searchText The current text value in the search input box. * @return {Boolean} Whether or not to display an item. */ searchFilter: function (item, searchText) { return item.name.toLowerCase().indexOf(searchText.toLowerCase()) !== -1; }, /** * Function that determines whether a folder can be uploaded to. */ canUpload: function(folder) { return true; }, /** * Called when a user tries to upload to a folder they don't have permission * to upload to. This is called before adding a file to the upload queue. */ uploadDenied: function(folder) {}, getExpandState: null, /** * Pre-preprocessing function for filenames that are added to the grid. A typical * use case is to strip invalid HTML from filenames. */ preprocessFilename: function(filename) { return filename; } }; HGrid._defaults = defaults; // Expose data structures via the HGrid namespace HGrid.Tree = Tree; HGrid.Leaf = Leaf; HGrid.Queue = Queue; // Constants HGrid.DEFAULT_INDENT = DEFAULT_INDENT; HGrid.ROOT_ID = ROOT_ID; HGrid.FOLDER = FOLDER; HGrid.ITEM = ITEM; /** * Custom Error for HGrid-related errors. * * @class HGrid.Error * @constructor */ HGrid.Error = function(message) { Error.call(this, message); this.name = 'HGrid.Error'; this.message = message || ''; }; HGrid.Error.prototype = Object.create(Error.prototype); /** * Construct an HGrid. * * @class HGrid * @constructor * @param {String} element CSS selector for the grid. * @param {Object} options */ function HGrid(selector, options) { var self = this; self.selector = selector; self.element = $(selector); // Merge defaults with options passed in self.options = $.extend({}, defaults, options); self.grid = null; // Set upon calling _initSlickGrid() self.dropzone = null; // Set upon calling _initDropzone() self.plugins = []; // Registered plugins if (self.options.searchInput) { var $searchInput = $(self.options.searchInput); if ($searchInput.length) { self.searchInput = $searchInput; } else { throw new HGrid.Error('Invalid selector for searchInput.'); } } else { self.searchInput = null; } if (typeof self.options.data === 'string') { // data is a URL, get the data asynchronously self.getFromServer(self.options.data, function(data, error) { self._initData(data); self.init(); } ); } else { // data is an object self._initData(self.options.data); self.init(); } } /** * Collapse all folders * @method collapseAll */ HGrid.prototype.collapseAll = function() { this.tree.collapseAt(1, true); return this; }; /** * Remove a folder's contents from the grid. * @method emptyFolder * @param {Object} item The folder item to empty. * @param {Boolean} [removeFolder] Also remove the folder. */ HGrid.prototype.emptyFolder = function(item, removeFolder) { item = typeof item === 'object' ? item : this.getByID(item); item._node.empty(removeFolder); if (!removeFolder) { this.getDataView().updateItem(item.id, item); } return this; }; /** * Helper for retrieving JSON data using AJAX. * @method getFromServer * @param {String} url * @param {Function} done Callback that receives the JSON data and an * error if there is one. * @return {jQuery xhr} The xhr object returned by jQuery.ajax. */ HGrid.prototype.getFromServer = function(url, done) { var self = this; var ajaxOpts = $.extend({}, { url: url, contentType: 'application/json', dataType: 'json', success: function(json) { done && done.call(self, json); }, error: function(xhr, textStatus, error) { done && done.call(self, null, error, textStatus); } }, self.options.ajaxOptions); return $.ajax(ajaxOpts); }; HGrid.prototype._initData = function(data) { var self = this; if (data) { // Tree.fromObject expects an Array, but `data` might be an array or an // object with `data' property if (Array.isArray(data)) { self.tree = Tree.fromObject(data); } else { self.tree = Tree.fromObject(data.data); } self.tree.updateDataView(); // Sync Tree with its wrapped dataview } else { self.tree = new Tree(); } return self; }; var Dropzone; HGrid.prototype.init = function() { this.setHeight(this.options.height) .setWidth(this.options.width) ._initSlickGrid() ._initDataView(); if (this.options.uploads) { if (typeof module === 'object') { Dropzone = require('dropzone'); } else { Dropzone = window.Dropzone; } if (typeof Dropzone === 'undefined') { throw new HGrid.Error('uploads=true requires DropZone to be loaded'); } this._initDropzone(); } // Attach the listeners last, after this.grid and this.dropzone are set this._initListeners(); // Collapse all top-level folders if lazy-loading if (this.isLazy()) { this.collapseAll(); } this.refreshExpandState(); this.options.init.call(this); return this; }; HGrid.prototype.refreshExpandState = function() { var self = this; if (self.options.getExpandState) { var data = self.getData(); for (var i = 0, item; item= data[i]; i++) { if (self.options.getExpandState.call(self, item)) { self.reloadFolder(item); } } } }; HGrid.prototype.setHeight = function(height) { if (height === 'auto') { this.options.slickgridOptions.autoHeight = true; } else { this.element.css('height', height); } return this; }; // TODO: always update column widths after setting width. HGrid.prototype.setWidth = function(width) { this.element.css('width', width); return this; }; // TODO: test me // HGrid column schmea => SlickGrid Formatter HGrid.prototype.makeFormatter = function(schema) { var self = this, view, html; var folderView = schema.folderView; var itemView = schema.itemView; var showExpander = schema.showExpander; var indentWidth = typeof schema.indent === 'number' ? schema.indent : DEFAULT_INDENT; var formatter = function(row, cell, value, colDef, item) { var rendererArgs = { colDef: colDef, row: row, cell: cell, indent: schema.indent, lazyLoad: self.isLazy() }; view = item.kind === FOLDER ? folderView : itemView; if (typeof view === 'function') { html = view.call(self, item, rendererArgs); // Returns the rendered HTML } else { // Use template html = HGrid.Format.tpl(view, item); } if (schema.isName) { html = asName(item, html); } if (showExpander) { var expander; if (typeof showExpander === 'function' && showExpander(item, rendererArgs)) { expander = item._collapsed ? HGrid.HtmlFactories.expandElem(item) : HGrid.HtmlFactories.collapseElem(item); } else { expander = ''; } html = [expander, html].join(''); } if (schema.indent) { html = withIndent(item, html, indentWidth); } return html; }; return formatter; }; // Hgrid column schemas => Slickgrid columns HGrid.prototype._makeSlickgridColumns = function(colSchemas) { var self = this; var columns = colSchemas.map(function(col) { if (!('formatter' in col)) { // Create the formatter function from the columns definition's // "folderView" and "itemView" properties col.formatter = self.makeFormatter.call(self, col); } if ('text' in col) { // Use 'text' instead of 'name' for column header text col.name = col.text; } if ('cssClass' in col) { col.cssClass = col.cssClass + ' ' + 'hg-cell'; } else { col.cssClass = 'hg-cell'; } return col; }); return columns; }; var requiredSlickgridOptions = { editable: false, asyncEditorLoading: false, enableCellNavigation: false, enableColumnReorder: false, // column reordering requires jquery-ui.sortable forceFitColumns: true, fullWidthRows: true }; /** * Constructs a Slick.Grid and Slick.Data.DataView from the data. * Sets this.grid. * @method _initSlickGrid * @private */ HGrid.prototype._initSlickGrid = function() { var self = this; // Convert column schemas to Slickgrid column definitions var columns = self._makeSlickgridColumns(self.options.columns); var options = $.extend({}, requiredSlickgridOptions, self.options.slickgridOptions); self.grid = new Slick.Grid(self.element.selector, self.tree.dataView, columns, options); return self; }; HGrid.prototype.removeHighlight = function(highlightClass) { var cssClass = highlightClass || this.options.highlightClass; this.element.find('.' + cssClass).removeClass(cssClass); return this; }; /** * Get the row element for an item, given its id. * @method getRowElement * @raises HGrid.Error if item does not exist in the DOM. */ HGrid.prototype.getRowElement = function(id) { if (typeof id === 'object') { id = id.id; } try { // May throw a TypeError if item is not yet rendered. return this.grid.getCellNode(this.getDataView().getRowById(id), 0).parentNode; // Rethrow as an HGrid error } catch (err) { throw new HGrid.Error('Row element is not rendered in the DOM.'); } }; HGrid.prototype.getPathToRoot = function(id) { var node = this.getNodeByID(id); return node.getPathToRoot(); }; HGrid.prototype.addHighlight = function(item, highlightClass) { var cssClass = highlightClass || this.options.highlightClass; this.removeHighlight(); var idToHighlight; if (item && item.kind === FOLDER) { idToHighlight = item.id; } else { idToHighlight = item.parentID; } var $rowElement; try{ $rowElement = $(this.getRowElement(idToHighlight)); } catch (err) { // Element to highlight is not in the DOM return this; } if ($rowElement) { $rowElement.addClass(cssClass); } return this; }; HGrid.prototype.folderContains = function(folderId, itemId) { return this.getPathToRoot(itemId).indexOf(folderId) >= 0; }; /** * SlickGrid events that the grid subscribes to. Mostly just delegates to one * of the callbacks in `options`. * For each funcion, `this` refers to the HGrid object. * @attribute slickEvents */ HGrid.prototype.slickEvents = { 'onClick': function(evt, args) { var item = this.getDataView().getItem(args.row); // Expand/collapse item if (this.canToggle(evt.target)) { this.toggleCollapse(item, evt); } this.options.onClick.call(this, evt, item); return this; }, 'onCellChange': function(evt, args) { this.getDataView().updateItem(args.item.id, args.item); return this; }, 'onMouseLeave': function(evt, args) { this.removeHighlight(); }, 'onSort': function(evt, args) { var col = args.sortCol; // column to sort var key = col.field || col.sortkey; // key to sort on if (!key) { throw new HGrid.Error('Sortable column does not define a `sortkey` to sort on.'); } this.tree.sort(key, args.sortAsc); this.tree.updateDataView(true); this.options.onSort.call(this, evt, col, args); } }; HGrid.prototype.getItemFromEvent = function(evt) { var cell = this.grid.getCellFromEvent(evt); if (cell) { return this.getDataView().getItem(cell.row); } else { return null; } }; HGrid.prototype.uploadToFolder = function(item) { this.currentTarget = item; this.setUploadTarget(item); this.dropzone.hiddenFileInput.click(); }; // TODO: untested HGrid.prototype.downloadItem = function(item) { var url; if (typeof this.options.downloadUrl === 'function') { url = this.options.downloadUrl(item); } else { url = this.options.downloadUrl; } if (url) { window.location = url; } return this; }; /** * Send a delete request to an item's download URL. */ HGrid.prototype.deleteFile = function(item, ajaxOptions) { var self = this; var url, method; // TODO: repetition here url = typeof this.options.deleteUrl === 'function' ? this.options.deleteUrl(item) : this.options.deleteUrl; method = typeof this.options.deleteMethod === 'function' ? this.options.deleteMethod(item) : this.options.deleteMethod; var options = $.extend({}, { url: url, type: method, success: function(data) { // Update parent self.updateItem(self.getByID(item.parentID)); self.removeItem(item.id); } }, self.options.ajaxOptions, ajaxOptions); var promise = null; if (url) { promise = $.ajax(options); } return promise; }; HGrid.prototype.currentTarget = null; // The item to upload to /** * Update the dropzone object's options dynamically. Lazily updates the * upload url, method, maxFilesize, etc. * @method setUploadTarget */ HGrid.prototype.setUploadTarget = function(item) { var self = this; // if upload url or upload method is a function, call it, passing in the target item, // and set dropzone to upload to the result function resolveParam(param) { return typeof param === 'function' ? param.call(self, item) : param; } if (self.currentTarget) { $.when( resolveParam(self.options.uploadHeaders), resolveParam(self.options.uploadUrl), resolveParam(self.options.uploadMethod), resolveParam(self.options.maxFilesize), resolveParam(self.options.acceptedFiles) ).done(function(uploadHeaders, uploadUrl, uploadMethod, maxFilesize, acceptedFiles) { self.dropzone.options.headers = uploadHeaders; self.dropzone.options.url = uploadUrl; self.dropzone.options.method = uploadMethod; self.dropzone.options.maxFilesize = maxFilesize; self.setAcceptedFiles(acceptedFiles); if (self.options.uploadAccept) { // Override dropzone accept callback. Just calls options.uploadAccept with the right params self.dropzone.options.accept = function(file, done) { return self.options.uploadAccept.call(self, file, item, done); }; } }); } }; HGrid.prototype.canUpload = function(item) { return Boolean(item && this.options.canUpload(item)); }; HGrid.prototype.denyUpload = function(targetItem) { // Need to throw an error to prevent dropzone's sequence of callbacks from firing this.options.uploadDenied.call(this, targetItem); throw new HGrid.Error('Upload permission denied.'); }; HGrid.prototype.validateTarget = function(targetItem) { if (!this.canUpload(targetItem)) { return this.denyUpload(targetItem); } else { return targetItem; } }; /** * DropZone events that the grid subscribes to. * For each function, `this` refers to the HGrid object. * These listeners are responsible for any setup that needs to occur before executing * the callbacks in `options`, e.g., adding a new row item to the grid, setting the * current upload target, adding special CSS classes * and passing necessary arguments to the options callbacks. * @attribute dropzoneEvents * @type {Object} */ HGrid.prototype.dropzoneEvents = { drop: function(evt) { this.removeHighlight(); this.validateTarget(this.currentTarget); // update the dropzone options, eg. dropzone.options.url this.setUploadTarget(this.currentTarget); this.options.onDrop.call(this, evt, this.currentTarget); }, dragleave: function(evt) { this.removeHighlight(); var item = this.getItemFromEvent(evt); this.options.onDragleave.call(this, evt, item); }, // Set the current upload target upon dragging a file onto the grid dragenter: function(evt) { var item = this.getItemFromEvent(evt); if (item) { if (item.kind === FOLDER) { this.currentTarget = item; } else { this.currentTarget = this.getByID(item.parentID); } } this.options.onDragenter.call(this, evt, item); }, dragover: function(evt) { var currentTarget = this.currentTarget; var item = this.getItemFromEvent(evt); if(this.canUpload(currentTarget)) { if (currentTarget) { this.addHighlight(currentTarget); } } this.options.onDragover.call(this, evt, item); }, dragend: function(evt) { this.removeHighlight(); }, // When a file is added, set currentTarget (the folder item to upload to) // and bind gridElement (the html element for the added row) and gridItem // (the added item object) to the file object addedfile: function(file) { var currentTarget = this.currentTarget; this.validateTarget(currentTarget); var addedItem; if (this.canUpload(currentTarget)){ // Add a new row addedItem = this.addItem({ name: this.options.preprocessFilename.call(this, file.name), kind: HGrid.ITEM, parentID: currentTarget.id }); var rowElem = this.getRowElement(addedItem.id), $rowElem = $(rowElem); // Save the item data and HTML element on the file object file.gridItem = addedItem; file.gridElement = rowElem; $rowElem.addClass('hg-upload-started'); } this.options.uploadAdded.call(this, file, file.gridItem, currentTarget); return addedItem; }, thumbnail: noop, // Just delegate error function to options.uploadError error: function(file, message) { var $rowElem = $(file.gridElement); $rowElem.addClass('hg-upload-error').removeClass('hg-upload-processing'); // Remove the added row var item = $.extend({}, file.gridItem); this.removeItem(file.gridItem.id); return this.options.uploadError.call(this, file, message, item, this.currentTarget); }, processing: function(file) { $(file.gridElement).addClass('hg-upload-processing'); this.currentTarget._processing = true; this.updateItem(this.currentTarget); this.options.uploadProcessing.call(this, file, file.gridItem, this.currentTarget); return this; }, uploadprogress: function(file, progress, bytesSent) { return this.options.uploadProgress.call(this, file, progress, bytesSent, file.gridItem); }, success: function(file, data) { $(file.gridElement).addClass('hg-upload-success'); return this.options.uploadSuccess.call(this, file, file.gridItem, data); }, complete: function(file) { $(file.gridElement).removeClass('hg-upload-processing'); this.currentTarget._processing = false; this.updateItem(this.currentTarget); return this.options.uploadComplete.call(this, file, file.gridItem); }, sending: function(file, xhr, formData) { return this.options.uploadSending(file, file.gridItem, xhr, formData); } }; /** * Wires up all the event handlers. * @method _initListeners * @private */ HGrid.prototype._initListeners = function() { var self = this, callbackName, fn; // Wire up all the slickgrid events for (callbackName in self.slickEvents) { fn = self.slickEvents[callbackName].bind(self); // make `this` object the grid self.grid[callbackName].subscribe(fn); } if (this.options.uploads) { // Wire up all the dropzone events for (callbackName in self.dropzoneEvents) { fn = self.dropzoneEvents[callbackName].bind(self); self.dropzone.on(callbackName, fn); } } // Attach extra listeners from options.listeners var userCallback = function(evt) { var row = self.getItemFromEvent(evt); return evt.data.listenerObj.callback(evt, row, evt.data.grid); }; // TODO: test me for (var i = 0, listener; listener = this.options.listeners[i]; i++) { self.element.on(listener.on, listener.selector, { listenerObj: listener, grid: self }, userCallback); } this.attachActionListeners(); if (self.searchInput) { self.searchInput.keyup(function (e) { self._searchText = this.value; self.getDataView().refresh(); self.grid.invalidate(); self.grid.render(); }); } }; /** * Attaches event listeners based on the actions defined in HGrid.Actions. * For example, if a "spook" action might be defined like so * * ``` * HGrid.Actions['spook'] = { * on: 'click', * callback: function(evt, row) { * alert('Boo!') * } * }; *``` * and a button is created using HGrid.Format.button * ``` * ... * Hgrid.Format.button(item, {text: 'Spook', action: 'spook'}) * ``` * a "click" event listener will automatically be added to the button with * the defined callback. * */ HGrid.prototype.attachActionListeners = function() { var self = this; // Register any new actions; $.extend(HGrid.Actions, self.options.actions); // This just calls the action's defined callback var actionCallback = function(evt) { var row = self.getItemFromEvent(evt); evt.data.actionObj.callback.call(self, evt, row); }; for (var actionName in HGrid.Actions) { var actionDef = HGrid.Actions[actionName]; this.element.on(actionDef.on, '[data-hg-action="' + actionName + '"]', { actionObj: actionDef }, actionCallback); } return this; }; /** * Filter used by SlickGrid for searching and expanding/collapsing items. * Receives an item and returns true if the item should be displayed in the * grid. * * @class hgFilter * @private * @returns {Boolean} Whether to display the item or not. */ function hgFilter(item, args) { var visible; if (args.grid && args.grid._searchText) { item.depth = 0; // Show search results without indent // Use search filter function visible = args.searchFilter.call(args.grid, item, args.grid._searchText); } else { item.depth = item._node.depth; // Restore indent visible = !item._hidden; // Hide collapsed elements } return visible; } // Expose collapse filter for testing purposes HGrid._hgFilter = hgFilter; /** * Sets up the DataView with the filter function. Must be executed after * initializing the Slick.Grid because the filter function needs access to the * data. * @method _initDataView * @private */ HGrid.prototype._initDataView = function() { var self = this; var dataView = this.getDataView(); dataView.beginUpdate(); dataView.setFilterArgs({ grid: self, searchFilter: self.options.searchFilter }); dataView.setFilter(hgFilter); dataView.endUpdate(); dataView.onRowCountChanged.subscribe(function(event, args) { self.grid.updateRowCount(); self.grid.render(); }); dataView.onRowsChanged.subscribe(function(event, args) { self.grid.invalidateRows(args.rows); self.grid.render(); }); return this; }; var requiredDropzoneOpts = { addRemoveLinks: false, previewTemplate: '
' // just a dummy template because dropzone requires it }; HGrid.prototype.setAcceptedFiles = function(fileTypes) { var acceptedFiles; if (Array.isArray(fileTypes)) { acceptedFiles = fileTypes.join(','); } else { acceptedFiles = fileTypes; } this.dropzone.options.acceptedFiles = acceptedFiles; return this; }; /** * Builds a new DropZone object and attaches it the "dropzone" attribute of * the grid. * @method _initDropZone * @private */ HGrid.prototype._initDropzone = function() { var uploadUrl, uploadMethod, headers, acceptedFiles = null; // If a param is a string, return that, otherwise the param is a function, // so the value will be computed later. function resolveParam(param, fallback){ return (typeof param === 'function' || param == null) ? fallback : param; } uploadUrl = resolveParam(this.options.uploadUrl, '/'); uploadMethod = resolveParam(this.options.uploadMethod, 'POST'); headers = resolveParam(this.options.uploadHeaders, {}); acceptedFiles = resolveParam(this.options.acceptedFiles, null); if (Array.isArray(acceptedFiles)){ acceptedFiles = acceptedFiles.join(','); } // Build up the options object, combining the HGrid options, required options, // and additional options var dropzoneOptions = $.extend({}, { url: uploadUrl, // Dropzone expects comma separated list acceptedFiles: acceptedFiles, maxFilesize: this.options.maxFilesize, method: uploadMethod, headers: headers }, requiredDropzoneOpts, this.options.dropzoneOptions); this.dropzone = new Dropzone(this.selector, dropzoneOptions); return this; }; HGrid.prototype.destroy = function() { this.element.html(''); this.grid.destroy(); if (this.dropzone) { this.dropzone.destroy(); } }; /** * Return the data as an array. * * @method getData * @return {Array} Array of data items in the DataView. */ HGrid.prototype.getData = function() { return this.getDataView().getItems(); }; /** * Get a datum by it's ID. */ HGrid.prototype.getByID = function(id) { var dataView = this.getDataView(); return dataView.getItemById(id); }; /** * Return the grid's underlying DataView. * @method getDataView * @return {Slick.Data.DataView} */ HGrid.prototype.getDataView = function() { return this.grid.getData(); }; HGrid.prototype.getRefreshHints = function (item) { var ignoreBefore = this.getDataView().getRowById(item.id); var hints = { expand: { isFilterNarrowing: false, isFilterExpanding: true, ignoreDiffsBefore: ignoreBefore }, collapse: { isFilterNarrowing: true, isFilterExpanding: false, ignoreDiffsBefore: ignoreBefore } }; return hints; }; HGrid.prototype.isLazy = function() { return Boolean(this.options.fetchUrl); // Assume lazy loading is enabled if fetchUrl is defined }; var LOADING_UNFINISHED = HGrid.LOADING_UNFINISHED = 'lu'; var LOADING_STARTED = HGrid.LOADING_STARTED = 'ls'; var LOADING_FINISHED = HGrid.LOADING_FINISHED = 'lf'; HGrid.prototype.setLoadingStatus = function(item, status) { item._node._load_status = status; this.updateItem(item); }; // TODO: test fetch callbacks HGrid.prototype._lazyLoad = function(item) { var self = this; var url = self.options.fetchUrl(item); if (url !== null) { self.options.fetchStart.call(self, item); self.setLoadingStatus(item, LOADING_STARTED); return self.getFromServer(url, function(newData, error) { if (!error) { self.addData(newData, item.id); self.setLoadingStatus(item, LOADING_FINISHED); self.refreshExpandState(); self.options.fetchSuccess.call(self, newData, item); } else { self.setLoadingStatus(item, LOADING_UNFINISHED); self.options.fetchError.call(self, error, item); throw new HGrid.Error('Could not fetch data from url: "' + url + '". Error: ' + error); } }); } return false; }; /** * Expand an item. Updates the dataview. * @method expandItem * @param {Object} item */ HGrid.prototype.expandItem = function(item, evt) { var self = this; item = typeof item === 'object' ? item : self.getByID(item); var node = self.getNodeByID(item.id); item._node.expand(); var dataview = self.getDataView(); var hints = self.getRefreshHints(item).expand; dataview.setRefreshHints(hints); self.getDataView().updateItem(item.id, item); if (self.isLazy() && (node._load_status === undefined || node._load_status === LOADING_UNFINISHED)) { this._lazyLoad(item); } self.options.onExpand.call(self, evt, item); return self; }; /** * Collapse an item. Updates the dataview. * @method collapseItem * @param {Object} item */ HGrid.prototype.collapseItem = function(item, evt) { item = typeof item === 'object' ? item : this.getByID(item); item._node.collapse(); var dataview = this.getDataView(); var hints = this.getRefreshHints(item).collapse; dataview.setRefreshHints(hints); dataview.updateItem(item.id, item); this.options.onCollapse.call(this, evt, item); return this; }; HGrid.prototype.updateItem = function(item) { return this.getDataView().updateItem(item.id, item); }; HGrid.prototype.isCollapsed = function(item) { return Boolean(item._collapsed); }; HGrid.prototype.canToggle = function(elem) { return $(elem).hasClass(HGrid.Html.toggleClass); }; /** * Add an item to the grid. * @method addItem * @param {Object} item Object with `name`, `kind`, and `parentID`. * If parentID is not specified, the new item is added to the root node. * Example: * `{name: 'New Folder', kind: 'folder', parentID: 123}` * @return {Object} The added item. */ HGrid.prototype.addItem = function(item) { var node, parentNode; // Create a new node for the item if (item.kind === HGrid.FOLDER) { node = new HGrid.Tree(item); } else { node = new HGrid.Leaf(item); } if (item.parentID == null) { parentNode = this.tree; } else { parentNode = this.getNodeByID(item.parentID); } parentNode.add(node, true); var newItem = this.getByID(node.id); this.options.onItemAdded.call(this, newItem); return newItem; }; /** * Add multiple items. * * Only one refresh is made to the grid after adding all the items. * @param {Array} items Array of items with "name", "kind", and "parentID". */ // FIXME: This method is slow, because the DataView's idx:id map needs to be updated // on every insert HGrid.prototype.addItems = function(items) { var self = this; this.batchUpdate(function() { for (var i = 0, len = items.length; i < len; i++) { var item = items[i]; self.addItem(item); } }); return this; }; HGrid.prototype.batchUpdate = function(func) { this.getDataView().beginUpdate(); func.call(this); this.getDataView().endUpdate(); }; /** * Add a new grid column * @method addColumn * Example: * ``` * grid.addColumn({id: 'size', name: 'File Size', field: 'filesize', width: 50}) * ``` * @param {Object} colSpec Column specification. See * https://github.com/mleibman/SlickGrid/wiki/Column-Options */ HGrid.prototype.addColumn = function(colSpec) { var columns = this.grid.getColumns(); columns.push(colSpec); this.grid.setColumns(columns); return this; }; /** * Remove a data item by id. * @method removeItem * @param {Number} id ID of the datum to remove. * @return {Object} The removed item */ HGrid.prototype.removeItem = function(id) { return this.tree.remove(id); }; /** * Return a HGrid.Tree or HGrid.Leaf node given an id. * @param {Number} id * @return {HGrid.Tree} The Tree or Leaf with the id. */ HGrid.prototype.getNodeByID = function(id) { if (id === HGrid.ROOT_ID || id == null) { return this.tree; } var item = this.getByID(id); return item._node; }; /** * Toggle an item's collapsed/expanded state. * @method toggleCollapse * @param {item} item A folder item */ HGrid.prototype.toggleCollapse = function(item, event) { if (item) { if (this.isCollapsed(item)) { this.expandItem(item, event); } else { this.collapseItem(item, event); } } return this; }; /** * Add more hierarchical data. The `data` param takes the same form as the * input data. * @param data Hierarchical data to add * @param {Number} parentID ID of the parent node to add the data to */ HGrid.prototype.addData = function(data, parentID) { var self = this; var tree = this.getNodeByID(parentID); var dataView = this.getDataView(); var toAdd; if (Array.isArray(data)) { toAdd = data; } else { // Data is an object with a `data` property toAdd = data.data; } // Loop in reverse order, so that grid items are inserted into the DOM // in the correct order. self.batchUpdate(function() { for (var i = toAdd.length - 1; i >= 0; i--) { var datum = toAdd[i]; var node; if (datum.kind === HGrid.FOLDER) { var collapse = self.isLazy(); var args = {collapse: collapse}; node = Tree.fromObject(datum, tree, args); } else { node = Leaf.fromObject(datum, tree); } tree.add(node, true); // ensure dataview is updated } }); // self.refreshExpandState(); return this; }; /** * Reset a node's loaded state to false. When the node is expanded again and * lazy loading is enabled, a new xhr request will be sent. */ HGrid.prototype.resetLoadedState = function(folder, status) { var self = this; if (status) { self.setLoadingStatus(folder, status); } else { self.setLoadingStatus(folder, LOADING_UNFINISHED); } return this; }; /** * Reload a folder contents. Will send a request even if lazy loading is enabled * @method reloadFolder */ HGrid.prototype.reloadFolder = function(folder) { this.resetLoadedState(folder); this.emptyFolder(folder); this.collapseItem(folder); this.expandItem(folder); return this; }; HGrid.prototype.render = function() { this.grid.render(); return this; }; HGrid.prototype.invalidate = function () { this.grid.invalidate(); return this; }; HGrid.prototype.refreshData = function() { this.getDataView().refresh(); }; HGrid.prototype.registerPlugin = function(plugin) { this.plugins.unshift(plugin); plugin.init(this); }; HGrid.prototype.unregisterPlugin = function(plugin) { var plugins = this.plugins; for (var i = plugins.length; i >= 0; i--) { if (plugins[i] === plugin) { if (plugins[i].destroy) { plugins[i].destroy(); } plugins.splice(i, 1); break; } } }; $.fn.hgrid = function(options) { this.each(function() { if (!this.id) { // Must have ID because SlickGrid requires a selector throw new HGrid.Error('Element must have an ID if initializing HGrid with jQuery'); } var selector = '#' + this.id; return new HGrid(selector, options); }); }; return HGrid; }).call(this, jQuery); /*! * jquery.event.drag - v 2.2 * Copyright (c) 2010 Three Dub Media - http://threedubmedia.com * Open Source MIT License - http://threedubmedia.com/code/license */ // Created: 2008-06-04 // Updated: 2012-05-21 // REQUIRES: jquery 1.7.x ;(function( $ ){ // add the jquery instance method $.fn.drag = function( str, arg, opts ){ // figure out the event type var type = typeof str == "string" ? str : "", // figure out the event handler... fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null; // fix the event type if ( type.indexOf("drag") !== 0 ) type = "drag"+ type; // were options passed opts = ( str == fn ? arg : opts ) || {}; // trigger or bind event handler return fn ? this.bind( type, opts, fn ) : this.trigger( type ); }; // local refs (increase compression) var $event = $.event, $special = $event.special, // configure the drag special event drag = $special.drag = { // these are the default settings defaults: { which: 1, // mouse button pressed to start drag sequence distance: 0, // distance dragged before dragstart not: ':input', // selector to suppress dragging on target elements handle: null, // selector to match handle target elements relative: false, // true to use "position", false to use "offset" drop: true, // false to suppress drop events, true or selector to allow click: false // false to suppress click events after dragend (no proxy) }, // the key name for stored drag data datakey: "dragdata", // prevent bubbling for better performance noBubble: true, // count bound related events add: function( obj ){ // read the interaction data var data = $.data( this, drag.datakey ), // read any passed options opts = obj.data || {}; // count another realted event data.related += 1; // extend data options bound with this event // don't iterate "opts" in case it is a node $.each( drag.defaults, function( key, def ){ if ( opts[ key ] !== undefined ) data[ key ] = opts[ key ]; }); }, // forget unbound related events remove: function(){ $.data( this, drag.datakey ).related -= 1; }, // configure interaction, capture settings setup: function(){ // check for related events if ( $.data( this, drag.datakey ) ) return; // initialize the drag data with copied defaults var data = $.extend({ related:0 }, drag.defaults ); // store the interaction data $.data( this, drag.datakey, data ); // bind the mousedown event, which starts drag interactions $event.add( this, "touchstart mousedown", drag.init, data ); // prevent image dragging in IE... if ( this.attachEvent ) this.attachEvent("ondragstart", drag.dontstart ); }, // destroy configured interaction teardown: function(){ var data = $.data( this, drag.datakey ) || {}; // check for related events if ( data.related ) return; // remove the stored data $.removeData( this, drag.datakey ); // remove the mousedown event $event.remove( this, "touchstart mousedown", drag.init ); // enable text selection drag.textselect( true ); // un-prevent image dragging in IE... if ( this.detachEvent ) this.detachEvent("ondragstart", drag.dontstart ); }, // initialize the interaction init: function( event ){ // sorry, only one touch at a time if ( drag.touched ) return; // the drag/drop interaction data var dd = event.data, results; // check the which directive if ( event.which != 0 && dd.which > 0 && event.which != dd.which ) return; // check for suppressed selector if ( $( event.target ).is( dd.not ) ) return; // check for handle selector if ( dd.handle && !$( event.target ).closest( dd.handle, event.currentTarget ).length ) return; drag.touched = event.type == 'touchstart' ? this : null; dd.propagates = 1; dd.mousedown = this; dd.interactions = [ drag.interaction( this, dd ) ]; dd.target = event.target; dd.pageX = event.pageX; dd.pageY = event.pageY; dd.dragging = null; // handle draginit event... results = drag.hijack( event, "draginit", dd ); // early cancel if ( !dd.propagates ) return; // flatten the result set results = drag.flatten( results ); // insert new interaction elements if ( results && results.length ){ dd.interactions = []; $.each( results, function(){ dd.interactions.push( drag.interaction( this, dd ) ); }); } // remember how many interactions are propagating dd.propagates = dd.interactions.length; // locate and init the drop targets if ( dd.drop !== false && $special.drop ) $special.drop.handler( event, dd ); // disable text selection drag.textselect( false ); // bind additional events... if ( drag.touched ) $event.add( drag.touched, "touchmove touchend", drag.handler, dd ); else $event.add( document, "mousemove mouseup", drag.handler, dd ); // helps prevent text selection or scrolling if ( !drag.touched || dd.live ) return false; }, // returns an interaction object interaction: function( elem, dd ){ var offset = $( elem )[ dd.relative ? "position" : "offset" ]() || { top:0, left:0 }; return { drag: elem, callback: new drag.callback(), droppable: [], offset: offset }; }, // handle drag-releatd DOM events handler: function( event ){ // read the data before hijacking anything var dd = event.data; // handle various events switch ( event.type ){ // mousemove, check distance, start dragging case !dd.dragging && 'touchmove': event.preventDefault(); case !dd.dragging && 'mousemove': // drag tolerance, x� + y� = distance� if ( Math.pow( event.pageX-dd.pageX, 2 ) + Math.pow( event.pageY-dd.pageY, 2 ) < Math.pow( dd.distance, 2 ) ) break; // distance tolerance not reached event.target = dd.target; // force target from "mousedown" event (fix distance issue) drag.hijack( event, "dragstart", dd ); // trigger "dragstart" if ( dd.propagates ) // "dragstart" not rejected dd.dragging = true; // activate interaction // mousemove, dragging case 'touchmove': event.preventDefault(); case 'mousemove': if ( dd.dragging ){ // trigger "drag" drag.hijack( event, "drag", dd ); if ( dd.propagates ){ // manage drop events if ( dd.drop !== false && $special.drop ) $special.drop.handler( event, dd ); // "dropstart", "dropend" break; // "drag" not rejected, stop } event.type = "mouseup"; // helps "drop" handler behave } // mouseup, stop dragging case 'touchend': case 'mouseup': default: if ( drag.touched ) $event.remove( drag.touched, "touchmove touchend", drag.handler ); // remove touch events else $event.remove( document, "mousemove mouseup", drag.handler ); // remove page events if ( dd.dragging ){ if ( dd.drop !== false && $special.drop ) $special.drop.handler( event, dd ); // "drop" drag.hijack( event, "dragend", dd ); // trigger "dragend" } drag.textselect( true ); // enable text selection // if suppressing click events... if ( dd.click === false && dd.dragging ) $.data( dd.mousedown, "suppress.click", new Date().getTime() + 5 ); dd.dragging = drag.touched = false; // deactivate element break; } }, // re-use event object for custom events hijack: function( event, type, dd, x, elem ){ // not configured if ( !dd ) return; // remember the original event and type var orig = { event:event.originalEvent, type:event.type }, // is the event drag related or drog related? mode = type.indexOf("drop") ? "drag" : "drop", // iteration vars result, i = x || 0, ia, $elems, callback, len = !isNaN( x ) ? x : dd.interactions.length; // modify the event type event.type = type; // remove the original event event.originalEvent = null; // initialize the results dd.results = []; // handle each interacted element do if ( ia = dd.interactions[ i ] ){ // validate the interaction if ( type !== "dragend" && ia.cancelled ) continue; // set the dragdrop properties on the event object callback = drag.properties( event, dd, ia ); // prepare for more results ia.results = []; // handle each element $( elem || ia[ mode ] || dd.droppable ).each(function( p, subject ){ // identify drag or drop targets individually callback.target = subject; // force propagtion of the custom event event.isPropagationStopped = function(){ return false; }; // handle the event result = subject ? $event.dispatch.call( subject, event, callback ) : null; // stop the drag interaction for this element if ( result === false ){ if ( mode == "drag" ){ ia.cancelled = true; dd.propagates -= 1; } if ( type == "drop" ){ ia[ mode ][p] = null; } } // assign any dropinit elements else if ( type == "dropinit" ) ia.droppable.push( drag.element( result ) || subject ); // accept a returned proxy element if ( type == "dragstart" ) ia.proxy = $( drag.element( result ) || ia.drag )[0]; // remember this result ia.results.push( result ); // forget the event result, for recycling delete event.result; // break on cancelled handler if ( type !== "dropinit" ) return result; }); // flatten the results dd.results[ i ] = drag.flatten( ia.results ); // accept a set of valid drop targets if ( type == "dropinit" ) ia.droppable = drag.flatten( ia.droppable ); // locate drop targets if ( type == "dragstart" && !ia.cancelled ) callback.update(); } while ( ++i < len ) // restore the original event & type event.type = orig.type; event.originalEvent = orig.event; // return all handler results return drag.flatten( dd.results ); }, // extend the callback object with drag/drop properties... properties: function( event, dd, ia ){ var obj = ia.callback; // elements obj.drag = ia.drag; obj.proxy = ia.proxy || ia.drag; // starting mouse position obj.startX = dd.pageX; obj.startY = dd.pageY; // current distance dragged obj.deltaX = event.pageX - dd.pageX; obj.deltaY = event.pageY - dd.pageY; // original element position obj.originalX = ia.offset.left; obj.originalY = ia.offset.top; // adjusted element position obj.offsetX = obj.originalX + obj.deltaX; obj.offsetY = obj.originalY + obj.deltaY; // assign the drop targets information obj.drop = drag.flatten( ( ia.drop || [] ).slice() ); obj.available = drag.flatten( ( ia.droppable || [] ).slice() ); return obj; }, // determine is the argument is an element or jquery instance element: function( arg ){ if ( arg && ( arg.jquery || arg.nodeType == 1 ) ) return arg; }, // flatten nested jquery objects and arrays into a single dimension array flatten: function( arr ){ return $.map( arr, function( member ){ return member && member.jquery ? $.makeArray( member ) : member && member.length ? drag.flatten( member ) : member; }); }, // toggles text selection attributes ON (true) or OFF (false) textselect: function( bool ){ $( document )[ bool ? "unbind" : "bind" ]("selectstart", drag.dontstart ) .css("MozUserSelect", bool ? "" : "none" ); // .attr("unselectable", bool ? "off" : "on" ) document.unselectable = bool ? "off" : "on"; }, // suppress "selectstart" and "ondragstart" events dontstart: function(){ return false; }, // a callback instance contructor callback: function(){} }; // callback methods drag.callback.prototype = { update: function(){ if ( $special.drop && this.available.length ) $.each( this.available, function( i ){ $special.drop.locate( this, i ); }); } }; // patch $.event.$dispatch to allow suppressing clicks var $dispatch = $event.dispatch; $event.dispatch = function( event ){ if ( $.data( this, "suppress."+ event.type ) - new Date().getTime() > 0 ){ $.removeData( this, "suppress."+ event.type ); return; } return $dispatch.apply( this, arguments ); }; // event fix hooks for touch events... var touchHooks = $event.fixHooks.touchstart = $event.fixHooks.touchmove = $event.fixHooks.touchend = $event.fixHooks.touchcancel = { props: "clientX clientY pageX pageY screenX screenY".split( " " ), filter: function( event, orig ) { if ( orig ){ var touched = ( orig.touches && orig.touches[0] ) || ( orig.changedTouches && orig.changedTouches[0] ) || null; // iOS webkit: touchstart, touchmove, touchend if ( touched ) $.each( touchHooks.props, function( i, prop ){ event[ prop ] = touched[ prop ]; }); } return event; } }; // share the same special event configuration with related events... $special.draginit = $special.dragstart = $special.dragend = drag; })( jQuery ); /*! * jquery.event.drop - v 2.2 * Copyright (c) 2010 Three Dub Media - http://threedubmedia.com * Open Source MIT License - http://threedubmedia.com/code/license */ // Created: 2008-06-04 // Updated: 2012-05-21 // REQUIRES: jquery 1.7.x, event.drag 2.2 ;(function($){ // secure $ jQuery alias // Events: drop, dropstart, dropend // add the jquery instance method $.fn.drop = function( str, arg, opts ){ // figure out the event type var type = typeof str == "string" ? str : "", // figure out the event handler... fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null; // fix the event type if ( type.indexOf("drop") !== 0 ) type = "drop"+ type; // were options passed opts = ( str == fn ? arg : opts ) || {}; // trigger or bind event handler return fn ? this.bind( type, opts, fn ) : this.trigger( type ); }; // DROP MANAGEMENT UTILITY // returns filtered drop target elements, caches their positions $.drop = function( opts ){ opts = opts || {}; // safely set new options... drop.multi = opts.multi === true ? Infinity : opts.multi === false ? 1 : !isNaN( opts.multi ) ? opts.multi : drop.multi; drop.delay = opts.delay || drop.delay; drop.tolerance = $.isFunction( opts.tolerance ) ? opts.tolerance : opts.tolerance === null ? null : drop.tolerance; drop.mode = opts.mode || drop.mode || 'intersect'; }; // local refs (increase compression) var $event = $.event, $special = $event.special, // configure the drop special event drop = $.event.special.drop = { // these are the default settings multi: 1, // allow multiple drop winners per dragged element delay: 20, // async timeout delay mode: 'overlap', // drop tolerance mode // internal cache targets: [], // the key name for stored drop data datakey: "dropdata", // prevent bubbling for better performance noBubble: true, // count bound related events add: function( obj ){ // read the interaction data var data = $.data( this, drop.datakey ); // count another realted event data.related += 1; }, // forget unbound related events remove: function(){ $.data( this, drop.datakey ).related -= 1; }, // configure the interactions setup: function(){ // check for related events if ( $.data( this, drop.datakey ) ) return; // initialize the drop element data var data = { related: 0, active: [], anyactive: 0, winner: 0, location: {} }; // store the drop data on the element $.data( this, drop.datakey, data ); // store the drop target in internal cache drop.targets.push( this ); }, // destroy the configure interaction teardown: function(){ var data = $.data( this, drop.datakey ) || {}; // check for related events if ( data.related ) return; // remove the stored data $.removeData( this, drop.datakey ); // reference the targeted element var element = this; // remove from the internal cache drop.targets = $.grep( drop.targets, function( target ){ return ( target !== element ); }); }, // shared event handler handler: function( event, dd ){ // local vars var results, $targets; // make sure the right data is available if ( !dd ) return; // handle various events switch ( event.type ){ // draginit, from $.event.special.drag case 'mousedown': // DROPINIT >> case 'touchstart': // DROPINIT >> // collect and assign the drop targets $targets = $( drop.targets ); if ( typeof dd.drop == "string" ) $targets = $targets.filter( dd.drop ); // reset drop data winner properties $targets.each(function(){ var data = $.data( this, drop.datakey ); data.active = []; data.anyactive = 0; data.winner = 0; }); // set available target elements dd.droppable = $targets; // activate drop targets for the initial element being dragged $special.drag.hijack( event, "dropinit", dd ); break; // drag, from $.event.special.drag case 'mousemove': // TOLERATE >> case 'touchmove': // TOLERATE >> drop.event = event; // store the mousemove event if ( !drop.timer ) // monitor drop targets drop.tolerate( dd ); break; // dragend, from $.event.special.drag case 'mouseup': // DROP >> DROPEND >> case 'touchend': // DROP >> DROPEND >> drop.timer = clearTimeout( drop.timer ); // delete timer if ( dd.propagates ){ $special.drag.hijack( event, "drop", dd ); $special.drag.hijack( event, "dropend", dd ); } break; } }, // returns the location positions of an element locate: function( elem, index ){ var data = $.data( elem, drop.datakey ), $elem = $( elem ), posi = $elem.offset() || {}, height = $elem.outerHeight(), width = $elem.outerWidth(), location = { elem: elem, width: width, height: height, top: posi.top, left: posi.left, right: posi.left + width, bottom: posi.top + height }; // drag elements might not have dropdata if ( data ){ data.location = location; data.index = index; data.elem = elem; } return location; }, // test the location positions of an element against another OR an X,Y coord contains: function( target, test ){ // target { location } contains test [x,y] or { location } return ( ( test[0] || test.left ) >= target.left && ( test[0] || test.right ) <= target.right && ( test[1] || test.top ) >= target.top && ( test[1] || test.bottom ) <= target.bottom ); }, // stored tolerance modes modes: { // fn scope: "$.event.special.drop" object // target with mouse wins, else target with most overlap wins 'intersect': function( event, proxy, target ){ return this.contains( target, [ event.pageX, event.pageY ] ) ? // check cursor 1e9 : this.modes.overlap.apply( this, arguments ); // check overlap }, // target with most overlap wins 'overlap': function( event, proxy, target ){ // calculate the area of overlap... return Math.max( 0, Math.min( target.bottom, proxy.bottom ) - Math.max( target.top, proxy.top ) ) * Math.max( 0, Math.min( target.right, proxy.right ) - Math.max( target.left, proxy.left ) ); }, // proxy is completely contained within target bounds 'fit': function( event, proxy, target ){ return this.contains( target, proxy ) ? 1 : 0; }, // center of the proxy is contained within target bounds 'middle': function( event, proxy, target ){ return this.contains( target, [ proxy.left + proxy.width * .5, proxy.top + proxy.height * .5 ] ) ? 1 : 0; } }, // sort drop target cache by by winner (dsc), then index (asc) sort: function( a, b ){ return ( b.winner - a.winner ) || ( a.index - b.index ); }, // async, recursive tolerance execution tolerate: function( dd ){ // declare local refs var i, drp, drg, data, arr, len, elem, // interaction iteration variables x = 0, ia, end = dd.interactions.length, // determine the mouse coords xy = [ drop.event.pageX, drop.event.pageY ], // custom or stored tolerance fn tolerance = drop.tolerance || drop.modes[ drop.mode ]; // go through each passed interaction... do if ( ia = dd.interactions[x] ){ // check valid interaction if ( !ia ) return; // initialize or clear the drop data ia.drop = []; // holds the drop elements arr = []; len = ia.droppable.length; // determine the proxy location, if needed if ( tolerance ) drg = drop.locate( ia.proxy ); // reset the loop i = 0; // loop each stored drop target do if ( elem = ia.droppable[i] ){ data = $.data( elem, drop.datakey ); drp = data.location; if ( !drp ) continue; // find a winner: tolerance function is defined, call it data.winner = tolerance ? tolerance.call( drop, drop.event, drg, drp ) // mouse position is always the fallback : drop.contains( drp, xy ) ? 1 : 0; arr.push( data ); } while ( ++i < len ); // loop // sort the drop targets arr.sort( drop.sort ); // reset the loop i = 0; // loop through all of the targets again do if ( data = arr[ i ] ){ // winners... if ( data.winner && ia.drop.length < drop.multi ){ // new winner... dropstart if ( !data.active[x] && !data.anyactive ){ // check to make sure that this is not prevented if ( $special.drag.hijack( drop.event, "dropstart", dd, x, data.elem )[0] !== false ){ data.active[x] = 1; data.anyactive += 1; } // if false, it is not a winner else data.winner = 0; } // if it is still a winner if ( data.winner ) ia.drop.push( data.elem ); } // losers... else if ( data.active[x] && data.anyactive == 1 ){ // former winner... dropend $special.drag.hijack( drop.event, "dropend", dd, x, data.elem ); data.active[x] = 0; data.anyactive -= 1; } } while ( ++i < len ); // loop } while ( ++x < end ) // loop // check if the mouse is still moving or is idle if ( drop.last && xy[0] == drop.last.pageX && xy[1] == drop.last.pageY ) delete drop.timer; // idle, don't recurse else // recurse drop.timer = setTimeout(function(){ drop.tolerate( dd ); }, drop.delay ); // remember event, to compare idleness drop.last = drop.event; } }; // share the same special event configuration with related events... $special.dropinit = $special.dropstart = $special.dropend = drop; })(jQuery); // confine scope /*** * Contains core SlickGrid classes. * @module Core * @namespace Slick */ (function ($) { // register namespace $.extend(true, window, { "Slick": { "Event": Event, "EventData": EventData, "EventHandler": EventHandler, "Range": Range, "NonDataRow": NonDataItem, "Group": Group, "GroupTotals": GroupTotals, "EditorLock": EditorLock, /*** * A global singleton editor lock. * @class GlobalEditorLock * @static * @constructor */ "GlobalEditorLock": new EditorLock() } }); /*** * An event object for passing data to event handlers and letting them control propagation. *This is pretty much identical to how W3C and jQuery implement events.
* @class EventData * @constructor */ function EventData() { var isPropagationStopped = false; var isImmediatePropagationStopped = false; /*** * Stops event from propagating up the DOM tree. * @method stopPropagation */ this.stopPropagation = function () { isPropagationStopped = true; }; /*** * Returns whether stopPropagation was called on this event object. * @method isPropagationStopped * @return {Boolean} */ this.isPropagationStopped = function () { return isPropagationStopped; }; /*** * Prevents the rest of the handlers from being executed. * @method stopImmediatePropagation */ this.stopImmediatePropagation = function () { isImmediatePropagationStopped = true; }; /*** * Returns whether stopImmediatePropagation was called on this event object.\ * @method isImmediatePropagationStopped * @return {Boolean} */ this.isImmediatePropagationStopped = function () { return isImmediatePropagationStopped; } } /*** * A simple publisher-subscriber implementation. * @class Event * @constructor */ function Event() { var handlers = []; /*** * Adds an event handler to be called when the event is fired. *Event handler will receive two arguments - an EventData
and the data
* object the event was fired with.
* @method subscribe
* @param fn {Function} Event handler.
*/
this.subscribe = function (fn) {
handlers.push(fn);
};
/***
* Removes an event handler added with subscribe(fn)
.
* @method unsubscribe
* @param fn {Function} Event handler to be removed.
*/
this.unsubscribe = function (fn) {
for (var i = handlers.length - 1; i >= 0; i--) {
if (handlers[i] === fn) {
handlers.splice(i, 1);
}
}
};
/***
* Fires an event notifying all subscribers.
* @method notify
* @param args {Object} Additional data object to be passed to all handlers.
* @param e {EventData}
* Optional.
* An EventData
object to be passed to all handlers.
* For DOM events, an existing W3C/jQuery event object can be passed in.
* @param scope {Object}
* Optional.
* The scope ("this") within which the handler will be executed.
* If not specified, the scope will be set to the Event
instance.
*/
this.notify = function (args, e, scope) {
e = e || new EventData();
scope = scope || this;
var returnValue;
for (var i = 0; i < handlers.length && !(e.isPropagationStopped() || e.isImmediatePropagationStopped()); i++) {
returnValue = handlers[i].call(scope, e, args);
}
return returnValue;
};
}
function EventHandler() {
var handlers = [];
this.subscribe = function (event, handler) {
handlers.push({
event: event,
handler: handler
});
event.subscribe(handler);
return this; // allow chaining
};
this.unsubscribe = function (event, handler) {
var i = handlers.length;
while (i--) {
if (handlers[i].event === event &&
handlers[i].handler === handler) {
handlers.splice(i, 1);
event.unsubscribe(handler);
return;
}
}
return this; // allow chaining
};
this.unsubscribeAll = function () {
var i = handlers.length;
while (i--) {
handlers[i].event.unsubscribe(handlers[i].handler);
}
handlers = [];
return this; // allow chaining
}
}
/***
* A structure containing a range of cells.
* @class Range
* @constructor
* @param fromRow {Integer} Starting row.
* @param fromCell {Integer} Starting cell.
* @param toRow {Integer} Optional. Ending row. Defaults to fromRow
.
* @param toCell {Integer} Optional. Ending cell. Defaults to fromCell
.
*/
function Range(fromRow, fromCell, toRow, toCell) {
if (toRow === undefined && toCell === undefined) {
toRow = fromRow;
toCell = fromCell;
}
/***
* @property fromRow
* @type {Integer}
*/
this.fromRow = Math.min(fromRow, toRow);
/***
* @property fromCell
* @type {Integer}
*/
this.fromCell = Math.min(fromCell, toCell);
/***
* @property toRow
* @type {Integer}
*/
this.toRow = Math.max(fromRow, toRow);
/***
* @property toCell
* @type {Integer}
*/
this.toCell = Math.max(fromCell, toCell);
/***
* Returns whether a range represents a single row.
* @method isSingleRow
* @return {Boolean}
*/
this.isSingleRow = function () {
return this.fromRow == this.toRow;
};
/***
* Returns whether a range represents a single cell.
* @method isSingleCell
* @return {Boolean}
*/
this.isSingleCell = function () {
return this.fromRow == this.toRow && this.fromCell == this.toCell;
};
/***
* Returns whether a range contains a given cell.
* @method contains
* @param row {Integer}
* @param cell {Integer}
* @return {Boolean}
*/
this.contains = function (row, cell) {
return row >= this.fromRow && row <= this.toRow &&
cell >= this.fromCell && cell <= this.toCell;
};
/***
* Returns a readable representation of a range.
* @method toString
* @return {String}
*/
this.toString = function () {
if (this.isSingleCell()) {
return "(" + this.fromRow + ":" + this.fromCell + ")";
}
else {
return "(" + this.fromRow + ":" + this.fromCell + " - " + this.toRow + ":" + this.toCell + ")";
}
}
}
/***
* A base class that all special / non-data rows (like Group and GroupTotals) derive from.
* @class NonDataItem
* @constructor
*/
function NonDataItem() {
this.__nonDataRow = true;
}
/***
* Information about a group of rows.
* @class Group
* @extends Slick.NonDataItem
* @constructor
*/
function Group() {
this.__group = true;
/**
* Grouping level, starting with 0.
* @property level
* @type {Number}
*/
this.level = 0;
/***
* Number of rows in the group.
* @property count
* @type {Integer}
*/
this.count = 0;
/***
* Grouping value.
* @property value
* @type {Object}
*/
this.value = null;
/***
* Formatted display value of the group.
* @property title
* @type {String}
*/
this.title = null;
/***
* Whether a group is collapsed.
* @property collapsed
* @type {Boolean}
*/
this.collapsed = false;
/***
* GroupTotals, if any.
* @property totals
* @type {GroupTotals}
*/
this.totals = null;
/**
* Rows that are part of the group.
* @property rows
* @type {Array}
*/
this.rows = [];
/**
* Sub-groups that are part of the group.
* @property groups
* @type {Array}
*/
this.groups = null;
/**
* A unique key used to identify the group. This key can be used in calls to DataView
* collapseGroup() or expandGroup().
* @property groupingKey
* @type {Object}
*/
this.groupingKey = null;
}
Group.prototype = new NonDataItem();
/***
* Compares two Group instances.
* @method equals
* @return {Boolean}
* @param group {Group} Group instance to compare to.
*/
Group.prototype.equals = function (group) {
return this.value === group.value &&
this.count === group.count &&
this.collapsed === group.collapsed;
};
/***
* Information about group totals.
* An instance of GroupTotals will be created for each totals row and passed to the aggregators
* so that they can store arbitrary data in it. That data can later be accessed by group totals
* formatters during the display.
* @class GroupTotals
* @extends Slick.NonDataItem
* @constructor
*/
function GroupTotals() {
this.__groupTotals = true;
/***
* Parent Group.
* @param group
* @type {Group}
*/
this.group = null;
}
GroupTotals.prototype = new NonDataItem();
/***
* A locking helper to track the active edit controller and ensure that only a single controller
* can be active at a time. This prevents a whole class of state and validation synchronization
* issues. An edit controller (such as SlickGrid) can query if an active edit is in progress
* and attempt a commit or cancel before proceeding.
* @class EditorLock
* @constructor
*/
function EditorLock() {
var activeEditController = null;
/***
* Returns true if a specified edit controller is active (has the edit lock).
* If the parameter is not specified, returns true if any edit controller is active.
* @method isActive
* @param editController {EditController}
* @return {Boolean}
*/
this.isActive = function (editController) {
return (editController ? activeEditController === editController : activeEditController !== null);
};
/***
* Sets the specified edit controller as the active edit controller (acquire edit lock).
* If another edit controller is already active, and exception will be thrown.
* @method activate
* @param editController {EditController} edit controller acquiring the lock
*/
this.activate = function (editController) {
if (editController === activeEditController) { // already activated?
return;
}
if (activeEditController !== null) {
throw "SlickGrid.EditorLock.activate: an editController is still active, can't activate another editController";
}
if (!editController.commitCurrentEdit) {
throw "SlickGrid.EditorLock.activate: editController must implement .commitCurrentEdit()";
}
if (!editController.cancelCurrentEdit) {
throw "SlickGrid.EditorLock.activate: editController must implement .cancelCurrentEdit()";
}
activeEditController = editController;
};
/***
* Unsets the specified edit controller as the active edit controller (release edit lock).
* If the specified edit controller is not the active one, an exception will be thrown.
* @method deactivate
* @param editController {EditController} edit controller releasing the lock
*/
this.deactivate = function (editController) {
if (activeEditController !== editController) {
throw "SlickGrid.EditorLock.deactivate: specified editController is not the currently active one";
}
activeEditController = null;
};
/***
* Attempts to commit the current edit by calling "commitCurrentEdit" method on the active edit
* controller and returns whether the commit attempt was successful (commit may fail due to validation
* errors, etc.). Edit controller's "commitCurrentEdit" must return true if the commit has succeeded
* and false otherwise. If no edit controller is active, returns true.
* @method commitCurrentEdit
* @return {Boolean}
*/
this.commitCurrentEdit = function () {
return (activeEditController ? activeEditController.commitCurrentEdit() : true);
};
/***
* Attempts to cancel the current edit by calling "cancelCurrentEdit" method on the active edit
* controller and returns whether the edit was successfully cancelled. If no edit controller is
* active, returns true.
* @method cancelCurrentEdit
* @return {Boolean}
*/
this.cancelCurrentEdit = function cancelCurrentEdit() {
return (activeEditController ? activeEditController.cancelCurrentEdit() : true);
};
}
})(jQuery);
(function ($) {
$.extend(true, window, {
Slick: {
Data: {
DataView: DataView,
Aggregators: {
Avg: AvgAggregator,
Min: MinAggregator,
Max: MaxAggregator,
Sum: SumAggregator
}
}
}
});
/***
* A sample Model implementation.
* Provides a filtered view of the underlying data.
*
* Relies on the data item having an "id" property uniquely identifying it.
*/
function DataView(options) {
var self = this;
var defaults = {
groupItemMetadataProvider: null,
inlineFilters: false
};
// private
var idProperty = "id"; // property holding a unique row id
var items = []; // data by index
var rows = []; // data by row
var idxById = {}; // indexes by id
var rowsById = null; // rows by id; lazy-calculated
var filter = null; // filter function
var updated = null; // updated item ids
var suspend = false; // suspends the recalculation
var sortAsc = true;
var fastSortField;
var sortComparer;
var refreshHints = {};
var prevRefreshHints = {};
var filterArgs;
var filteredItems = [];
var compiledFilter;
var compiledFilterWithCaching;
var filterCache = [];
// grouping
var groupingInfoDefaults = {
getter: null,
formatter: null,
comparer: function(a, b) { return a.value - b.value; },
predefinedValues: [],
aggregators: [],
aggregateEmpty: false,
aggregateCollapsed: false,
aggregateChildGroups: false,
collapsed: false,
displayTotalsRow: true
};
var groupingInfos = [];
var groups = [];
var toggledGroupsByLevel = [];
var groupingDelimiter = ':|:';
var pagesize = 0;
var pagenum = 0;
var totalRows = 0;
// events
var onRowCountChanged = new Slick.Event();
var onRowsChanged = new Slick.Event();
var onPagingInfoChanged = new Slick.Event();
options = $.extend(true, {}, defaults, options);
function beginUpdate() {
suspend = true;
}
function endUpdate() {
suspend = false;
refresh();
}
function setRefreshHints(hints) {
refreshHints = hints;
}
function setFilterArgs(args) {
filterArgs = args;
}
function updateIdxById(startingIndex) {
startingIndex = startingIndex || 0;
var id;
for (var i = startingIndex, l = items.length; i < l; i++) {
id = items[i][idProperty];
if (id === undefined) {
throw "Each data element must implement a unique 'id' property";
}
idxById[id] = i;
}
}
function ensureIdUniqueness() {
var id;
for (var i = 0, l = items.length; i < l; i++) {
id = items[i][idProperty];
if (id === undefined || idxById[id] !== i) {
throw "Each data element must implement a unique 'id' property";
}
}
}
function getItems() {
return items;
}
function setItems(data, objectIdProperty) {
if (objectIdProperty !== undefined) {
idProperty = objectIdProperty;
}
items = filteredItems = data;
idxById = {};
updateIdxById();
ensureIdUniqueness();
refresh();
}
function setPagingOptions(args) {
if (args.pageSize != undefined) {
pagesize = args.pageSize;
pagenum = pagesize ? Math.min(pagenum, Math.max(0, Math.ceil(totalRows / pagesize) - 1)) : 0;
}
if (args.pageNum != undefined) {
pagenum = Math.min(args.pageNum, Math.max(0, Math.ceil(totalRows / pagesize) - 1));
}
onPagingInfoChanged.notify(getPagingInfo(), null, self);
refresh();
}
function getPagingInfo() {
var totalPages = pagesize ? Math.max(1, Math.ceil(totalRows / pagesize)) : 1;
return {pageSize: pagesize, pageNum: pagenum, totalRows: totalRows, totalPages: totalPages};
}
function sort(comparer, ascending) {
sortAsc = ascending;
sortComparer = comparer;
fastSortField = null;
if (ascending === false) {
items.reverse();
}
items.sort(comparer);
if (ascending === false) {
items.reverse();
}
idxById = {};
updateIdxById();
refresh();
}
/***
* Provides a workaround for the extremely slow sorting in IE.
* Does a [lexicographic] sort on a give column by temporarily overriding Object.prototype.toString
* to return the value of that field and then doing a native Array.sort().
*/
function fastSort(field, ascending) {
sortAsc = ascending;
fastSortField = field;
sortComparer = null;
var oldToString = Object.prototype.toString;
Object.prototype.toString = (typeof field == "function") ? field : function () {
return this[field]
};
// an extra reversal for descending sort keeps the sort stable
// (assuming a stable native sort implementation, which isn't true in some cases)
if (ascending === false) {
items.reverse();
}
items.sort();
Object.prototype.toString = oldToString;
if (ascending === false) {
items.reverse();
}
idxById = {};
updateIdxById();
refresh();
}
function reSort() {
if (sortComparer) {
sort(sortComparer, sortAsc);
} else if (fastSortField) {
fastSort(fastSortField, sortAsc);
}
}
function setFilter(filterFn) {
filter = filterFn;
if (options.inlineFilters) {
compiledFilter = compileFilter();
compiledFilterWithCaching = compileFilterWithCaching();
}
refresh();
}
function getGrouping() {
return groupingInfos;
}
function setGrouping(groupingInfo) {
if (!options.groupItemMetadataProvider) {
options.groupItemMetadataProvider = new Slick.Data.GroupItemMetadataProvider();
}
groups = [];
toggledGroupsByLevel = [];
groupingInfo = groupingInfo || [];
groupingInfos = (groupingInfo instanceof Array) ? groupingInfo : [groupingInfo];
for (var i = 0; i < groupingInfos.length; i++) {
var gi = groupingInfos[i] = $.extend(true, {}, groupingInfoDefaults, groupingInfos[i]);
gi.getterIsAFn = typeof gi.getter === "function";
// pre-compile accumulator loops
gi.compiledAccumulators = [];
var idx = gi.aggregators.length;
while (idx--) {
gi.compiledAccumulators[idx] = compileAccumulatorLoop(gi.aggregators[idx]);
}
toggledGroupsByLevel[i] = {};
}
refresh();
}
/**
* @deprecated Please use {@link setGrouping}.
*/
function groupBy(valueGetter, valueFormatter, sortComparer) {
if (valueGetter == null) {
setGrouping([]);
return;
}
setGrouping({
getter: valueGetter,
formatter: valueFormatter,
comparer: sortComparer
});
}
/**
* @deprecated Please use {@link setGrouping}.
*/
function setAggregators(groupAggregators, includeCollapsed) {
if (!groupingInfos.length) {
throw new Error("At least one grouping must be specified before calling setAggregators().");
}
groupingInfos[0].aggregators = groupAggregators;
groupingInfos[0].aggregateCollapsed = includeCollapsed;
setGrouping(groupingInfos);
}
function getItemByIdx(i) {
return items[i];
}
function getIdxById(id) {
return idxById[id];
}
function ensureRowsByIdCache() {
if (!rowsById) {
rowsById = {};
for (var i = 0, l = rows.length; i < l; i++) {
rowsById[rows[i][idProperty]] = i;
}
}
}
function getRowById(id) {
ensureRowsByIdCache();
return rowsById[id];
}
function getItemById(id) {
return items[idxById[id]];
}
function mapIdsToRows(idArray) {
var rows = [];
ensureRowsByIdCache();
for (var i = 0; i < idArray.length; i++) {
var row = rowsById[idArray[i]];
if (row != null) {
rows[rows.length] = row;
}
}
return rows;
}
function mapRowsToIds(rowArray) {
var ids = [];
for (var i = 0; i < rowArray.length; i++) {
if (rowArray[i] < rows.length) {
ids[ids.length] = rows[rowArray[i]][idProperty];
}
}
return ids;
}
function updateItem(id, item) {
if (idxById[id] === undefined || id !== item[idProperty]) {
throw "Invalid or non-matching id";
}
items[idxById[id]] = item;
if (!updated) {
updated = {};
}
updated[id] = true;
refresh();
}
function insertItem(insertBefore, item) {
items.splice(insertBefore, 0, item);
updateIdxById(insertBefore);
refresh();
}
function addItem(item) {
items.push(item);
updateIdxById(items.length - 1);
refresh();
}
function deleteItem(id) {
var idx = idxById[id];
if (idx === undefined) {
throw "Invalid id";
}
delete idxById[id];
items.splice(idx, 1);
updateIdxById(idx);
refresh();
}
function getLength() {
return rows.length;
}
function getItem(i) {
return rows[i];
}
function getItemMetadata(i) {
var item = rows[i];
if (item === undefined) {
return null;
}
// overrides for grouping rows
if (item.__group) {
return options.groupItemMetadataProvider.getGroupRowMetadata(item);
}
// overrides for totals rows
if (item.__groupTotals) {
return options.groupItemMetadataProvider.getTotalsRowMetadata(item);
}
return null;
}
function expandCollapseAllGroups(level, collapse) {
if (level == null) {
for (var i = 0; i < groupingInfos.length; i++) {
toggledGroupsByLevel[i] = {};
groupingInfos[i].collapsed = collapse;
}
} else {
toggledGroupsByLevel[level] = {};
groupingInfos[level].collapsed = collapse;
}
refresh();
}
/**
* @param level {Number} Optional level to collapse. If not specified, applies to all levels.
*/
function collapseAllGroups(level) {
expandCollapseAllGroups(level, true);
}
/**
* @param level {Number} Optional level to expand. If not specified, applies to all levels.
*/
function expandAllGroups(level) {
expandCollapseAllGroups(level, false);
}
function expandCollapseGroup(level, groupingKey, collapse) {
toggledGroupsByLevel[level][groupingKey] = groupingInfos[level].collapsed ^ collapse;
refresh();
}
/**
* @param varArgs Either a Slick.Group's "groupingKey" property, or a
* variable argument list of grouping values denoting a unique path to the row. For
* example, calling collapseGroup('high', '10%') will collapse the '10%' subgroup of
* the 'high' setGrouping.
*/
function collapseGroup(varArgs) {
var args = Array.prototype.slice.call(arguments);
var arg0 = args[0];
if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) {
expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, true);
} else {
expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), true);
}
}
/**
* @param varArgs Either a Slick.Group's "groupingKey" property, or a
* variable argument list of grouping values denoting a unique path to the row. For
* example, calling expandGroup('high', '10%') will expand the '10%' subgroup of
* the 'high' setGrouping.
*/
function expandGroup(varArgs) {
var args = Array.prototype.slice.call(arguments);
var arg0 = args[0];
if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) {
expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, false);
} else {
expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), false);
}
}
function getGroups() {
return groups;
}
function extractGroups(rows, parentGroup) {
var group;
var val;
var groups = [];
var groupsByVal = [];
var r;
var level = parentGroup ? parentGroup.level + 1 : 0;
var gi = groupingInfos[level];
for (var i = 0, l = gi.predefinedValues.length; i < l; i++) {
val = gi.predefinedValues[i];
group = groupsByVal[val];
if (!group) {
group = new Slick.Group();
group.value = val;
group.level = level;
group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val;
groups[groups.length] = group;
groupsByVal[val] = group;
}
}
for (var i = 0, l = rows.length; i < l; i++) {
r = rows[i];
val = gi.getterIsAFn ? gi.getter(r) : r[gi.getter];
group = groupsByVal[val];
if (!group) {
group = new Slick.Group();
group.value = val;
group.level = level;
group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val;
groups[groups.length] = group;
groupsByVal[val] = group;
}
group.rows[group.count++] = r;
}
if (level < groupingInfos.length - 1) {
for (var i = 0; i < groups.length; i++) {
group = groups[i];
group.groups = extractGroups(group.rows, group);
}
}
groups.sort(groupingInfos[level].comparer);
return groups;
}
// TODO: lazy totals calculation
function calculateGroupTotals(group) {
// TODO: try moving iterating over groups into compiled accumulator
var gi = groupingInfos[group.level];
var isLeafLevel = (group.level == groupingInfos.length);
var totals = new Slick.GroupTotals();
var agg, idx = gi.aggregators.length;
while (idx--) {
agg = gi.aggregators[idx];
agg.init();
gi.compiledAccumulators[idx].call(agg,
(!isLeafLevel && gi.aggregateChildGroups) ? group.groups : group.rows);
agg.storeResult(totals);
}
totals.group = group;
group.totals = totals;
}
function calculateTotals(groups, level) {
level = level || 0;
var gi = groupingInfos[level];
var idx = groups.length, g;
while (idx--) {
g = groups[idx];
if (g.collapsed && !gi.aggregateCollapsed) {
continue;
}
// Do a depth-first aggregation so that parent setGrouping aggregators can access subgroup totals.
if (g.groups) {
calculateTotals(g.groups, level + 1);
}
if (gi.aggregators.length && (
gi.aggregateEmpty || g.rows.length || (g.groups && g.groups.length))) {
calculateGroupTotals(g);
}
}
}
function finalizeGroups(groups, level) {
level = level || 0;
var gi = groupingInfos[level];
var groupCollapsed = gi.collapsed;
var toggledGroups = toggledGroupsByLevel[level];
var idx = groups.length, g;
while (idx--) {
g = groups[idx];
g.collapsed = groupCollapsed ^ toggledGroups[g.groupingKey];
g.title = gi.formatter ? gi.formatter(g) : g.value;
if (g.groups) {
finalizeGroups(g.groups, level + 1);
// Let the non-leaf setGrouping rows get garbage-collected.
// They may have been used by aggregates that go over all of the descendants,
// but at this point they are no longer needed.
g.rows = [];
}
}
}
function flattenGroupedRows(groups, level) {
level = level || 0;
var gi = groupingInfos[level];
var groupedRows = [], rows, gl = 0, g;
for (var i = 0, l = groups.length; i < l; i++) {
g = groups[i];
groupedRows[gl++] = g;
if (!g.collapsed) {
rows = g.groups ? flattenGroupedRows(g.groups, level + 1) : g.rows;
for (var j = 0, jj = rows.length; j < jj; j++) {
groupedRows[gl++] = rows[j];
}
}
if (g.totals && gi.displayTotalsRow && (!g.collapsed || gi.aggregateCollapsed)) {
groupedRows[gl++] = g.totals;
}
}
return groupedRows;
}
function getFunctionInfo(fn) {
var fnRegex = /^function[^(]*\(([^)]*)\)\s*{([\s\S]*)}$/;
var matches = fn.toString().match(fnRegex);
return {
params: matches[1].split(","),
body: matches[2]
};
}
function compileAccumulatorLoop(aggregator) {
var accumulatorInfo = getFunctionInfo(aggregator.accumulate);
var fn = new Function(
"_items",
"for (var " + accumulatorInfo.params[0] + ", _i=0, _il=_items.length; _i<_il; _i++) {" +
accumulatorInfo.params[0] + " = _items[_i]; " +
accumulatorInfo.body +
"}"
);
fn.displayName = fn.name = "compiledAccumulatorLoop";
return fn;
}
function compileFilter() {
var filterInfo = getFunctionInfo(filter);
var filterBody = filterInfo.body
.replace(/return false[;}]/gi, "{ continue _coreloop; }")
.replace(/return true[;}]/gi, "{ _retval[_idx++] = $item$; continue _coreloop; }")
.replace(/return ([^;}]+?);/gi,
"{ if ($1) { _retval[_idx++] = $item$; }; continue _coreloop; }");
// This preserves the function template code after JS compression,
// so that replace() commands still work as expected.
var tpl = [
//"function(_items, _args) { ",
"var _retval = [], _idx = 0; ",
"var $item$, $args$ = _args; ",
"_coreloop: ",
"for (var _i = 0, _il = _items.length; _i < _il; _i++) { ",
"$item$ = _items[_i]; ",
"$filter$; ",
"} ",
"return _retval; "
//"}"
].join("");
tpl = tpl.replace(/\$filter\$/gi, filterBody);
tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]);
tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]);
var fn = new Function("_items,_args", tpl);
fn.displayName = fn.name = "compiledFilter";
return fn;
}
function compileFilterWithCaching() {
var filterInfo = getFunctionInfo(filter);
var filterBody = filterInfo.body
.replace(/return false[;}]/gi, "{ continue _coreloop; }")
.replace(/return true[;}]/gi, "{ _cache[_i] = true;_retval[_idx++] = $item$; continue _coreloop; }")
.replace(/return ([^;}]+?);/gi,
"{ if ((_cache[_i] = $1)) { _retval[_idx++] = $item$; }; continue _coreloop; }");
// This preserves the function template code after JS compression,
// so that replace() commands still work as expected.
var tpl = [
//"function(_items, _args, _cache) { ",
"var _retval = [], _idx = 0; ",
"var $item$, $args$ = _args; ",
"_coreloop: ",
"for (var _i = 0, _il = _items.length; _i < _il; _i++) { ",
"$item$ = _items[_i]; ",
"if (_cache[_i]) { ",
"_retval[_idx++] = $item$; ",
"continue _coreloop; ",
"} ",
"$filter$; ",
"} ",
"return _retval; "
//"}"
].join("");
tpl = tpl.replace(/\$filter\$/gi, filterBody);
tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]);
tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]);
var fn = new Function("_items,_args,_cache", tpl);
fn.displayName = fn.name = "compiledFilterWithCaching";
return fn;
}
function uncompiledFilter(items, args) {
var retval = [], idx = 0;
for (var i = 0, ii = items.length; i < ii; i++) {
if (filter(items[i], args)) {
retval[idx++] = items[i];
}
}
return retval;
}
function uncompiledFilterWithCaching(items, args, cache) {
var retval = [], idx = 0, item;
for (var i = 0, ii = items.length; i < ii; i++) {
item = items[i];
if (cache[i]) {
retval[idx++] = item;
} else if (filter(item, args)) {
retval[idx++] = item;
cache[i] = true;
}
}
return retval;
}
function getFilteredAndPagedItems(items) {
if (filter) {
var batchFilter = options.inlineFilters ? compiledFilter : uncompiledFilter;
var batchFilterWithCaching = options.inlineFilters ? compiledFilterWithCaching : uncompiledFilterWithCaching;
if (refreshHints.isFilterNarrowing) {
filteredItems = batchFilter(filteredItems, filterArgs);
} else if (refreshHints.isFilterExpanding) {
filteredItems = batchFilterWithCaching(items, filterArgs, filterCache);
} else if (!refreshHints.isFilterUnchanged) {
filteredItems = batchFilter(items, filterArgs);
}
} else {
// special case: if not filtering and not paging, the resulting
// rows collection needs to be a copy so that changes due to sort
// can be caught
filteredItems = pagesize ? items : items.concat();
}
// get the current page
var paged;
if (pagesize) {
if (filteredItems.length < pagenum * pagesize) {
pagenum = Math.floor(filteredItems.length / pagesize);
}
paged = filteredItems.slice(pagesize * pagenum, pagesize * pagenum + pagesize);
} else {
paged = filteredItems;
}
return {totalRows: filteredItems.length, rows: paged};
}
function getRowDiffs(rows, newRows) {
var item, r, eitherIsNonData, diff = [];
var from = 0, to = newRows.length;
if (refreshHints && refreshHints.ignoreDiffsBefore) {
from = Math.max(0,
Math.min(newRows.length, refreshHints.ignoreDiffsBefore));
}
if (refreshHints && refreshHints.ignoreDiffsAfter) {
to = Math.min(newRows.length,
Math.max(0, refreshHints.ignoreDiffsAfter));
}
for (var i = from, rl = rows.length; i < to; i++) {
if (i >= rl) {
diff[diff.length] = i;
} else {
item = newRows[i];
r = rows[i];
if ((groupingInfos.length && (eitherIsNonData = (item.__nonDataRow) || (r.__nonDataRow)) &&
item.__group !== r.__group ||
item.__group && !item.equals(r))
|| (eitherIsNonData &&
// no good way to compare totals since they are arbitrary DTOs
// deep object comparison is pretty expensive
// always considering them 'dirty' seems easier for the time being
(item.__groupTotals || r.__groupTotals))
|| item[idProperty] != r[idProperty]
|| (updated && updated[item[idProperty]])
) {
diff[diff.length] = i;
}
}
}
return diff;
}
function recalc(_items) {
rowsById = null;
if (refreshHints.isFilterNarrowing != prevRefreshHints.isFilterNarrowing ||
refreshHints.isFilterExpanding != prevRefreshHints.isFilterExpanding) {
filterCache = [];
}
var filteredItems = getFilteredAndPagedItems(_items);
totalRows = filteredItems.totalRows;
var newRows = filteredItems.rows;
groups = [];
if (groupingInfos.length) {
groups = extractGroups(newRows);
if (groups.length) {
calculateTotals(groups);
finalizeGroups(groups);
newRows = flattenGroupedRows(groups);
}
}
var diff = getRowDiffs(rows, newRows);
rows = newRows;
return diff;
}
function refresh() {
if (suspend) {
return;
}
var countBefore = rows.length;
var totalRowsBefore = totalRows;
var diff = recalc(items, filter); // pass as direct refs to avoid closure perf hit
// if the current page is no longer valid, go to last page and recalc
// we suffer a performance penalty here, but the main loop (recalc) remains highly optimized
if (pagesize && totalRows < pagenum * pagesize) {
pagenum = Math.max(0, Math.ceil(totalRows / pagesize) - 1);
diff = recalc(items, filter);
}
updated = null;
prevRefreshHints = refreshHints;
refreshHints = {};
if (totalRowsBefore != totalRows) {
onPagingInfoChanged.notify(getPagingInfo(), null, self);
}
if (countBefore != rows.length) {
onRowCountChanged.notify({previous: countBefore, current: rows.length}, null, self);
}
if (diff.length > 0) {
onRowsChanged.notify({rows: diff}, null, self);
}
}
function syncGridSelection(grid, preserveHidden) {
var self = this;
var selectedRowIds = self.mapRowsToIds(grid.getSelectedRows());;
var inHandler;
function update() {
if (selectedRowIds.length > 0) {
inHandler = true;
var selectedRows = self.mapIdsToRows(selectedRowIds);
if (!preserveHidden) {
selectedRowIds = self.mapRowsToIds(selectedRows);
}
grid.setSelectedRows(selectedRows);
inHandler = false;
}
}
grid.onSelectedRowsChanged.subscribe(function(e, args) {
if (inHandler) { return; }
selectedRowIds = self.mapRowsToIds(grid.getSelectedRows());
});
this.onRowsChanged.subscribe(update);
this.onRowCountChanged.subscribe(update);
}
function syncGridCellCssStyles(grid, key) {
var hashById;
var inHandler;
// since this method can be called after the cell styles have been set,
// get the existing ones right away
storeCellCssStyles(grid.getCellCssStyles(key));
function storeCellCssStyles(hash) {
hashById = {};
for (var row in hash) {
var id = rows[row][idProperty];
hashById[id] = hash[row];
}
}
function update() {
if (hashById) {
inHandler = true;
ensureRowsByIdCache();
var newHash = {};
for (var id in hashById) {
var row = rowsById[id];
if (row != undefined) {
newHash[row] = hashById[id];
}
}
grid.setCellCssStyles(key, newHash);
inHandler = false;
}
}
grid.onCellCssStylesChanged.subscribe(function(e, args) {
if (inHandler) { return; }
if (key != args.key) { return; }
if (args.hash) {
storeCellCssStyles(args.hash);
}
});
this.onRowsChanged.subscribe(update);
this.onRowCountChanged.subscribe(update);
}
return {
// methods
"beginUpdate": beginUpdate,
"endUpdate": endUpdate,
"setPagingOptions": setPagingOptions,
"getPagingInfo": getPagingInfo,
"getItems": getItems,
"setItems": setItems,
"setFilter": setFilter,
"sort": sort,
"fastSort": fastSort,
"reSort": reSort,
"setGrouping": setGrouping,
"getGrouping": getGrouping,
"groupBy": groupBy,
"setAggregators": setAggregators,
"collapseAllGroups": collapseAllGroups,
"expandAllGroups": expandAllGroups,
"collapseGroup": collapseGroup,
"expandGroup": expandGroup,
"getGroups": getGroups,
"getIdxById": getIdxById,
"getRowById": getRowById,
"getItemById": getItemById,
"getItemByIdx": getItemByIdx,
"mapRowsToIds": mapRowsToIds,
"mapIdsToRows": mapIdsToRows,
"setRefreshHints": setRefreshHints,
"setFilterArgs": setFilterArgs,
"refresh": refresh,
"updateItem": updateItem,
"insertItem": insertItem,
"addItem": addItem,
"deleteItem": deleteItem,
"syncGridSelection": syncGridSelection,
"syncGridCellCssStyles": syncGridCellCssStyles,
// data provider methods
"getLength": getLength,
"getItem": getItem,
"getItemMetadata": getItemMetadata,
// events
"onRowCountChanged": onRowCountChanged,
"onRowsChanged": onRowsChanged,
"onPagingInfoChanged": onPagingInfoChanged
};
}
function AvgAggregator(field) {
this.field_ = field;
this.init = function () {
this.count_ = 0;
this.nonNullCount_ = 0;
this.sum_ = 0;
};
this.accumulate = function (item) {
var val = item[this.field_];
this.count_++;
if (val != null && val !== "" && val !== NaN) {
this.nonNullCount_++;
this.sum_ += parseFloat(val);
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.avg) {
groupTotals.avg = {};
}
if (this.nonNullCount_ != 0) {
groupTotals.avg[this.field_] = this.sum_ / this.nonNullCount_;
}
};
}
function MinAggregator(field) {
this.field_ = field;
this.init = function () {
this.min_ = null;
};
this.accumulate = function (item) {
var val = item[this.field_];
if (val != null && val !== "" && val !== NaN) {
if (this.min_ == null || val < this.min_) {
this.min_ = val;
}
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.min) {
groupTotals.min = {};
}
groupTotals.min[this.field_] = this.min_;
}
}
function MaxAggregator(field) {
this.field_ = field;
this.init = function () {
this.max_ = null;
};
this.accumulate = function (item) {
var val = item[this.field_];
if (val != null && val !== "" && val !== NaN) {
if (this.max_ == null || val > this.max_) {
this.max_ = val;
}
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.max) {
groupTotals.max = {};
}
groupTotals.max[this.field_] = this.max_;
}
}
function SumAggregator(field) {
this.field_ = field;
this.init = function () {
this.sum_ = null;
};
this.accumulate = function (item) {
var val = item[this.field_];
if (val != null && val !== "" && val !== NaN) {
this.sum_ += parseFloat(val);
}
};
this.storeResult = function (groupTotals) {
if (!groupTotals.sum) {
groupTotals.sum = {};
}
groupTotals.sum[this.field_] = this.sum_;
}
}
// TODO: add more built-in aggregators
// TODO: merge common aggregators in one to prevent needles iterating
})(jQuery);
/**
* @license
* (c) 2009-2012 Michael Leibman
* michael{dot}leibman{at}gmail{dot}com
* http://github.com/mleibman/slickgrid
*
* Distributed under MIT license.
* All rights reserved.
*
* SlickGrid v2.1
*
* NOTES:
* Cell/row DOM manipulations are done directly bypassing jQuery's DOM manipulation methods.
* This increases the speed dramatically, but can only be done safely because there are no event handlers
* or data associated with any cell/row DOM nodes. Cell editors must make sure they implement .destroy()
* and do proper cleanup.
*/
// make sure required JavaScript modules are loaded
if (typeof jQuery === "undefined") {
throw "SlickGrid requires jquery module to be loaded";
}
if (!jQuery.fn.drag) {
throw "SlickGrid requires jquery.event.drag module to be loaded";
}
if (typeof Slick === "undefined") {
throw "slick.core.js not loaded";
}
(function ($) {
// Slick.Grid
$.extend(true, window, {
Slick: {
Grid: SlickGrid
}
});
// shared across all grids on the page
var scrollbarDimensions;
var maxSupportedCssHeight; // browser's breaking point
//////////////////////////////////////////////////////////////////////////////////////////////
// SlickGrid class implementation (available as Slick.Grid)
/**
* Creates a new instance of the grid.
* @class SlickGrid
* @constructor
* @param {Node} container Container node to create the grid in.
* @param {Array,Object} data An array of objects for databinding.
* @param {Array} columns An array of column definitions.
* @param {Object} options Grid options.
**/
function SlickGrid(container, data, columns, options) {
// settings
var defaults = {
explicitInitialization: false,
rowHeight: 25,
defaultColumnWidth: 80,
enableAddRow: false,
leaveSpaceForNewRows: false,
editable: false,
autoEdit: true,
enableCellNavigation: true,
enableColumnReorder: true,
asyncEditorLoading: false,
asyncEditorLoadDelay: 100,
forceFitColumns: false,
enableAsyncPostRender: false,
asyncPostRenderDelay: 50,
autoHeight: false,
editorLock: Slick.GlobalEditorLock,
showHeaderRow: false,
headerRowHeight: 25,
showTopPanel: false,
topPanelHeight: 25,
formatterFactory: null,
editorFactory: null,
cellFlashingCssClass: "flashing",
selectedCellCssClass: "selected",
multiSelect: true,
enableTextSelectionOnCells: false,
dataItemColumnValueExtractor: null,
fullWidthRows: false,
multiColumnSort: false,
defaultFormatter: defaultFormatter,
forceSyncScrolling: false,
// BJG: Modified from original Slickgrid for OSF
addExtraRowsAtEnd: 0
};
var columnDefaults = {
name: "",
resizable: true,
sortable: false,
minWidth: 30,
rerenderOnResize: false,
headerCssClass: null,
defaultSortAsc: true,
focusable: true,
selectable: true
};
// scroller
var th; // virtual height
var h; // real scrollable height
var ph; // page height
var n; // number of pages
var cj; // "jumpiness" coefficient
var page = 0; // current page
var offset = 0; // current page offset
var vScrollDir = 1;
// private
var initialized = false;
var $container;
var uid = "slickgrid_" + Math.round(1000000 * Math.random());
var self = this;
var $focusSink, $focusSink2;
var $headerScroller;
var $headers;
var $headerRow, $headerRowScroller, $headerRowSpacer;
var $topPanelScroller;
var $topPanel;
var $viewport;
var $canvas;
var $style;
var $boundAncestors;
var stylesheet, columnCssRulesL, columnCssRulesR;
var viewportH, viewportW;
var canvasWidth;
var viewportHasHScroll, viewportHasVScroll;
var headerColumnWidthDiff = 0, headerColumnHeightDiff = 0, // border+padding
cellWidthDiff = 0, cellHeightDiff = 0;
var absoluteColumnMinWidth;
var numberOfRows = 0;
var tabbingDirection = 1;
var activePosX;
var activeRow, activeCell;
var activeCellNode = null;
var currentEditor = null;
var serializedEditorValue;
var editController;
var rowsCache = {};
var renderedRows = 0;
var numVisibleRows;
var prevScrollTop = 0;
var scrollTop = 0;
var lastRenderedScrollTop = 0;
var lastRenderedScrollLeft = 0;
var prevScrollLeft = 0;
var scrollLeft = 0;
var selectionModel;
var selectedRows = [];
var plugins = [];
var cellCssClasses = {};
var columnsById = {};
var sortColumns = [];
var columnPosLeft = [];
var columnPosRight = [];
// async call handles
var h_editorLoader = null;
var h_render = null;
var h_postrender = null;
var postProcessedRows = {};
var postProcessToRow = null;
var postProcessFromRow = null;
// perf counters
var counter_rows_rendered = 0;
var counter_rows_removed = 0;
//////////////////////////////////////////////////////////////////////////////////////////////
// Initialization
function init() {
$container = $(container);
if ($container.length < 1) {
throw new Error("SlickGrid requires a valid container, " + container + " does not exist in the DOM.");
}
// calculate these only once and share between grid instances
maxSupportedCssHeight = maxSupportedCssHeight || getMaxSupportedCssHeight();
scrollbarDimensions = scrollbarDimensions || measureScrollbar();
options = $.extend({}, defaults, options);
validateAndEnforceOptions();
columnDefaults.width = options.defaultColumnWidth;
columnsById = {};
for (var i = 0; i < columns.length; i++) {
var m = columns[i] = $.extend({}, columnDefaults, columns[i]);
columnsById[m.id] = i;
if (m.minWidth && m.width < m.minWidth) {
m.width = m.minWidth;
}
if (m.maxWidth && m.width > m.maxWidth) {
m.width = m.maxWidth;
}
}
// validate loaded JavaScript modules against requested options
if (options.enableColumnReorder && !$.fn.sortable) {
throw new Error("SlickGrid's 'enableColumnReorder = true' option requires jquery-ui.sortable module to be loaded");
}
editController = {
"commitCurrentEdit": commitCurrentEdit,
"cancelCurrentEdit": cancelCurrentEdit
};
$container
.empty()
.css("overflow", "hidden")
.css("outline", 0)
.addClass(uid)
.addClass("ui-widget");
// set up a positioning container if needed
if (!/relative|absolute|fixed/.test($container.css("position"))) {
$container.css("position", "relative");
}
$focusSink = $("