diff --git a/UI/JsLibraries/backbone.backgrid.filter.js b/UI/JsLibraries/backbone.backgrid.filter.js index abc95a386..17449a53c 100644 --- a/UI/JsLibraries/backbone.backgrid.filter.js +++ b/UI/JsLibraries/backbone.backgrid.filter.js @@ -6,10 +6,12 @@ Licensed under the MIT @license. */ -(function ($, _, Backbone, Backgrid, lunr) { +(function (root) { "use strict"; + var Backbone = root.Backbone, Backgrid = root.Backgrid, lunr = root.lunr; + /** ServerSideFilter is a search form widget that submits a query to the server for filtering the current collection. @@ -36,14 +38,17 @@ /** @property {string} [name='q'] Query key */ name: "q", - /** @property The HTML5 placeholder to appear beneath the search box. */ + /** + @property {string} [placeholder] The HTML5 placeholder to appear beneath + the search box. + */ placeholder: null, /** @param {Object} options @param {Backbone.Collection} options.collection - @param {String} [options.name] - @param {String} [options.placeholder] + @param {string} [options.name] + @param {string} [options.placeholder] */ initialize: function (options) { Backgrid.requireOptions(options, ["collection"]); @@ -51,16 +56,21 @@ this.name = options.name || this.name; this.placeholder = options.placeholder || this.placeholder; + // Persist the query on pagination var collection = this.collection, self = this; if (Backbone.PageableCollection && collection instanceof Backbone.PageableCollection && collection.mode == "server") { collection.queryParams[this.name] = function () { - return self.$el.find("input[type=text]").val(); + return self.searchBox().val() || null; }; } }, + searchBox: function () { + return this.$el.find("input[type=text]"); + }, + /** Upon search form submission, this event handler constructs a query parameter object and pass it to Collection#fetch for server-side @@ -68,9 +78,22 @@ */ search: function (e) { if (e) e.preventDefault(); + var data = {}; - data[this.name] = this.$el.find("input[type=text]").val(); - this.collection.fetch({data: data}); + + // go back to the first page on search + var collection = this.collection; + if (Backbone.PageableCollection && + collection instanceof Backbone.PageableCollection && + collection.mode == "server") { + collection.state.currentPage = 1; + } + else { + var query = this.searchBox().val(); + if (query) data[this.name] = query; + } + + collection.fetch({data: data, reset: true}); }, /** @@ -79,8 +102,8 @@ */ clear: function (e) { if (e) e.preventDefault(); - this.$("input[type=text]").val(null); - this.collection.fetch(); + this.searchBox().val(null); + this.collection.fetch({reset: true}); }, /** @@ -115,8 +138,7 @@ e.preventDefault(); this.clear(); }, - "change input[type=text]": "search", - "keyup input[type=text]": "search", + "keydown input[type=text]": "search", "submit": function (e) { e.preventDefault(); this.search(); @@ -124,14 +146,14 @@ }, /** - @property {?Array.} A list of model field names to search - for matches. If null, all of the fields will be searched. + @property {?Array.} [fields] A list of model field names to + search for matches. If null, all of the fields will be searched. */ fields: null, /** - @property wait The time in milliseconds to wait since for since the last - change to the search box's value before searching. This value can be + @property [wait=149] The time in milliseconds to wait since for since the + last change to the search box's value before searching. This value can be adjusted depending on how often the search box is used and how large the search index is. */ @@ -143,9 +165,9 @@ @param {Object} options @param {Backbone.Collection} options.collection - @param {String} [options.placeholder] - @param {String} [options.fields] - @param {String} [options.wait=149] + @param {string} [options.placeholder] + @param {string} [options.fields] + @param {string} [options.wait=149] */ initialize: function (options) { ServerSideFilter.prototype.initialize.apply(this, arguments); @@ -155,11 +177,8 @@ this._debounceMethods(["search", "clear"]); - var collection = this.collection; + var collection = this.collection = this.collection.fullCollection || this.collection; var shadowCollection = this.shadowCollection = collection.clone(); - shadowCollection.url = collection.url; - shadowCollection.sync = collection.sync; - shadowCollection.parse = collection.parse; this.listenTo(collection, "add", function (model, collection, options) { shadowCollection.add(model, options); @@ -167,9 +186,15 @@ this.listenTo(collection, "remove", function (model, collection, options) { shadowCollection.remove(model, options); }); - this.listenTo(collection, "sort reset", function (collection, options) { + this.listenTo(collection, "sort", function (col) { + if (!this.searchBox().val()) shadowCollection.reset(col.models); + }); + this.listenTo(collection, "reset", function (col, options) { options = _.extend({reindex: true}, options || {}); - if (options.reindex) shadowCollection.reset(collection.models); + if (options.reindex && col === collection && + options.from == null && options.to == null) { + shadowCollection.reset(col.models); + } }); }, @@ -218,7 +243,9 @@ when all the matches have been found. */ search: function () { - var matcher = _.bind(this.makeMatcher(this.$("input[type=text]").val()), this); + var matcher = _.bind(this.makeMatcher(this.searchBox().val()), this); + var col = this.collection; + if (col.pageableCollection) col.pageableCollection.getFirstPage({silent: true}); this.collection.reset(this.shadowCollection.filter(matcher), {reindex: false}); }, @@ -226,7 +253,7 @@ Clears the search box and reset the collection to its original. */ clear: function () { - this.$("input[type=text]").val(null); + this.searchBox().val(null); this.collection.reset(this.shadowCollection.models, {reindex: false}); } @@ -262,7 +289,7 @@ @param {Object} options @param {Backbone.Collection} options.collection - @param {String} [options.placeholder] + @param {string} [options.placeholder] @param {string} [options.ref] `lunrjs` document reference attribute name. @param {Object} [options.fields] A hash of `lunrjs` index field names and boost value. @@ -273,7 +300,7 @@ this.ref = options.ref || this.ref; - var collection = this.collection; + var collection = this.collection = this.collection.fullCollection || this.collection; this.listenTo(collection, "add", this.addToIndex); this.listenTo(collection, "remove", this.removeFromIndex); this.listenTo(collection, "reset", this.resetIndex); @@ -351,15 +378,17 @@ query answer. */ search: function () { - var searchResults = this.index.search(this.$("input[type=text]").val()); + var searchResults = this.index.search(this.searchBox().val()); var models = []; for (var i = 0; i < searchResults.length; i++) { var result = searchResults[i]; models.push(this.shadowCollection.get(result.ref)); } - this.collection.reset(models, {reindex: false}); + var col = this.collection; + if (col.pageableCollection) col.pageableCollection.getFirstPage({silent: true}); + col.reset(models, {reindex: false}); } }); -}(jQuery, _, Backbone, Backgrid, lunr)); +}(this)); diff --git a/UI/JsLibraries/backbone.backgrid.js b/UI/JsLibraries/backbone.backgrid.js index 387e93e1b..57462660f 100644 --- a/UI/JsLibraries/backbone.backgrid.js +++ b/UI/JsLibraries/backbone.backgrid.js @@ -41,10 +41,6 @@ if (!String.prototype.trim || ws.trim()) { }; } -function capitalize(s) { - return String.fromCharCode(s.charCodeAt(0) - 32) + s.slice(1); -} - function lpad(str, length, padstr) { var paddingLen = length - (str + '').length; paddingLen = paddingLen < 0 ? 0 : paddingLen; @@ -72,7 +68,9 @@ var Backgrid = root.Backgrid = { resolveNameToClass: function (name, suffix) { if (_.isString(name)) { - var key = _.map(name.split('-'), function (e) { return capitalize(e); }).join('') + suffix; + var key = _.map(name.split('-'), function (e) { + return e.slice(0, 1).toUpperCase() + e.slice(1); + }).join('') + suffix; var klass = Backgrid[key] || Backgrid.Extension[key]; if (_.isUndefined(klass)) { throw new ReferenceError("Class '" + key + "' not found"); @@ -81,7 +79,17 @@ var Backgrid = root.Backgrid = { } return name; + }, + + callByNeed: function () { + var value = arguments[0]; + if (!_.isFunction(value)) return value; + + var context = arguments[1]; + var args = [].slice.call(arguments, 2); + return value.apply(context, !!(args + '') ? args : void 0); } + }; _.extend(Backgrid, Backbone.Events); @@ -99,7 +107,7 @@ _.extend(Backgrid, Backbone.Events); var Command = Backgrid.Command = function (evt) { _.extend(this, { altKey: !!evt.altKey, - char: evt.char, + "char": evt["char"], charCode: evt.charCode, ctrlKey: !!evt.ctrlKey, key: evt.key, @@ -737,12 +745,33 @@ var Cell = Backgrid.Cell = Backbone.View.extend({ if (!(this.column instanceof Column)) { this.column = new Column(this.column); } - this.formatter = Backgrid.resolveNameToClass(this.column.get("formatter") || this.formatter, "Formatter"); + + var column = this.column, model = this.model, $el = this.$el; + + this.formatter = Backgrid.resolveNameToClass(column.get("formatter") || + this.formatter, "Formatter"); + this.editor = Backgrid.resolveNameToClass(this.editor, "CellEditor"); - this.listenTo(this.model, "change:" + this.column.get("name"), function () { - if (!this.$el.hasClass("editor")) this.render(); + + this.listenTo(model, "change:" + column.get("name"), function () { + if (!$el.hasClass("editor")) this.render(); }); - this.listenTo(this.model, "backgrid:error", this.renderError); + + this.listenTo(model, "backgrid:error", this.renderError); + + this.listenTo(column, "change:editable change:sortable change:renderable", + function (column) { + var changed = column.changedAttributes(); + for (var key in changed) { + if (changed.hasOwnProperty(key)) { + $el.toggleClass(key, changed[key]); + } + } + }); + + if (column.get("editable")) $el.addClass("editable"); + if (column.get("sortable")) $el.addClass("sortable"); + if (column.get("renderable")) $el.addClass("renderable"); }, /** @@ -779,7 +808,8 @@ var Cell = Backgrid.Cell = Backbone.View.extend({ var model = this.model; var column = this.column; - if (column.get("editable")) { + var editable = Backgrid.callByNeed(column.get("editable"), column, model); + if (editable) { this.currentEditor = new this.editor({ column: this.column, @@ -828,7 +858,7 @@ var Cell = Backgrid.Cell = Backbone.View.extend({ */ remove: function () { if (this.currentEditor) { - this.currentEditor.remove.apply(this, arguments); + this.currentEditor.remove.apply(this.currentEditor, arguments); delete this.currentEditor; } return Backbone.View.prototype.remove.apply(this, arguments); @@ -1483,6 +1513,7 @@ var Column = Backgrid.Column = Backbone.Model.extend({ editable: true, renderable: true, formatter: undefined, + sortValue: undefined, cell: undefined, headerCell: undefined }, @@ -1491,22 +1522,36 @@ var Column = Backgrid.Column = Backbone.Model.extend({ Initializes this Column instance. @param {Object} attrs Column attributes. + @param {string} attrs.name The name of the model attribute. + @param {string|Backgrid.Cell} attrs.cell The cell type. If this is a string, the capitalized form will be used to look up a cell class in Backbone, i.e.: string => StringCell. If a Cell subclass is supplied, it is initialized with a hash of parameters. If a Cell instance is supplied, it is used directly. + @param {string|Backgrid.HeaderCell} [attrs.headerCell] The header cell type. + @param {string} [attrs.label] The label to show in the header. - @param {boolean} [attrs.sortable=true] - @param {boolean} [attrs.editable=true] - @param {boolean} [attrs.renderable=true] - @param {Backgrid.CellFormatter|Object|string} [attrs.formatter] The + + @param {boolean|string} [attrs.sortable=true] + + @param {boolean|string} [attrs.editable=true] + + @param {boolean|string} [attrs.renderable=true] + + @param {Backgrid.CellFormatter | Object | string} [attrs.formatter] The formatter to use to convert between raw model values and user input. + @param {(function(Backbone.Model, string): Object) | string} [sortValue] The + function to use to extract a value from the model for comparison during + sorting. If this value is a string, a method with the same name will be + looked up from the column instance. + @throws {TypeError} If attrs.cell or attrs.options are not supplied. - @throws {ReferenceError} If attrs.cell is a string but a cell class of + + @throws {ReferenceError} If formatter is a string but a formatter class of said name cannot be found in the Backgrid module. See: @@ -1522,8 +1567,32 @@ var Column = Backgrid.Column = Backbone.Model.extend({ } var headerCell = Backgrid.resolveNameToClass(this.get("headerCell"), "HeaderCell"); + var cell = Backgrid.resolveNameToClass(this.get("cell"), "Cell"); - this.set({ cell: cell, headerCell: headerCell }, { silent: true }); + + var sortValue = this.get("sortValue"); + if (sortValue == null) sortValue = function (model, colName) { + return model.get(colName); + }; + else if (_.isString(sortValue)) sortValue = this[sortValue]; + + var sortable = this.get("sortable"); + if (_.isString(sortable)) sortable = this[sortable]; + + var editable = this.get("editable"); + if (_.isString(editable)) editable = this[editable]; + + var renderable = this.get("renderable"); + if (_.isString(renderable)) renderable = this[renderable]; + + this.set({ + cell: cell, + headerCell: headerCell, + sortable: sortable, + editable: editable, + renderable: renderable, + sortValue: sortValue + }, { silent: true }); } }); @@ -1587,22 +1656,11 @@ var Row = Backgrid.Row = Backbone.View.extend({ cells.push(this.makeCell(columns.at(i), options)); } - this.listenTo(columns, "change:renderable", function (column, renderable) { - for (var i = 0; i < cells.length; i++) { - var cell = cells[i]; - if (cell.column.get("name") == column.get("name")) { - if (renderable) cell.$el.show(); else cell.$el.hide(); - } - } - }); - this.listenTo(columns, "add", function (column, columns) { var i = columns.indexOf(column); var cell = this.makeCell(column, options); cells.splice(i, 0, cell); - if (!cell.column.get("renderable")) cell.$el.hide(); - var $el = this.$el; if (i === 0) { $el.prepend(cell.render().$el); @@ -1646,11 +1704,8 @@ var Row = Backgrid.Row = Backbone.View.extend({ this.$el.empty(); var fragment = document.createDocumentFragment(); - for (var i = 0; i < this.cells.length; i++) { - var cell = this.cells[i]; - fragment.appendChild(cell.render().el); - if (!cell.column.get("renderable")) cell.$el.hide(); + fragment.appendChild(this.cells[i].render().el); } this.el.appendChild(fragment); @@ -1766,7 +1821,24 @@ var HeaderCell = Backgrid.HeaderCell = Backbone.View.extend({ if (!(this.column instanceof Column)) { this.column = new Column(this.column); } + this.listenTo(this.collection, "backgrid:sort", this._resetCellDirection); + + var column = this.column, $el = this.$el; + + this.listenTo(column, "change:editable change:sortable change:renderable", + function (column) { + var changed = column.changedAttributes(); + for (var key in changed) { + if (changed.hasOwnProperty(key)) { + $el.toggleClass(key, changed[key]); + } + } + }); + + if (column.get("editable")) $el.addClass("editable"); + if (column.get("sortable")) $el.addClass("sortable"); + if (column.get("renderable")) $el.addClass("renderable"); }, /** @@ -1793,9 +1865,9 @@ var HeaderCell = Backgrid.HeaderCell = Backbone.View.extend({ @private */ - _resetCellDirection: function (sortByColName, direction, comparator, collection) { + _resetCellDirection: function (columnToSort, direction, comparator, collection) { if (collection == this.collection) { - if (sortByColName !== this.column.get("name")) this.direction(null); + if (columnToSort !== this.column) this.direction(null); else this.direction(direction); } }, @@ -1808,34 +1880,12 @@ var HeaderCell = Backgrid.HeaderCell = Backbone.View.extend({ onClick: function (e) { e.preventDefault(); - var columnName = this.column.get("name"); - - if (this.column.get("sortable")) { - if (this.direction() === "ascending") { - this.sort(columnName, "descending", function (left, right) { - var leftVal = left.get(columnName); - var rightVal = right.get(columnName); - if (leftVal === rightVal) { - return 0; - } - else if (leftVal > rightVal) { return -1; } - return 1; - }); - } - else if (this.direction() === "descending") { - this.sort(columnName, null); - } - else { - this.sort(columnName, "ascending", function (left, right) { - var leftVal = left.get(columnName); - var rightVal = right.get(columnName); - if (leftVal === rightVal) { - return 0; - } - else if (leftVal < rightVal) { return -1; } - return 1; - }); - } + var column = this.column; + var sortable = Backgrid.callByNeed(column.get("sortable"), column, this.model); + if (sortable) { + if (this.direction() === "ascending") this.sort(column, "descending"); + else if (this.direction() === "descending") this.sort(column, null); + else this.sort(column, "ascending"); } }, @@ -1852,31 +1902,37 @@ var HeaderCell = Backgrid.HeaderCell = Backbone.View.extend({ and the current page will then be returned. Triggers a Backbone `backgrid:sort` event from the collection when done - with the column name, direction, comparator and a reference to the - collection. + with the column, direction, comparator and a reference to the collection. - @param {string} columnName + @param {Backgrid.Column} column @param {null|"ascending"|"descending"} direction - @param {function(*, *): number} [comparator] See [Backbone.Collection#comparator](http://backbonejs.org/#Collection-comparator) */ - sort: function (columnName, direction, comparator) { - - comparator = comparator || this._cidComparator; + sort: function (column, direction) { var collection = this.collection; - if (Backbone.PageableCollection && collection instanceof Backbone.PageableCollection) { - var order; - if (direction === "ascending") order = -1; - else if (direction === "descending") order = 1; - else order = null; + var order; + if (direction === "ascending") order = -1; + else if (direction === "descending") order = 1; + else order = null; - collection.setSorting(order ? columnName : null, order); + var comparator = this.makeComparator(column.get("name"), order, + order ? + column.get("sortValue") : + function (model) { + return model.cid; + }); + + if (Backbone.PageableCollection && + collection instanceof Backbone.PageableCollection) { + + collection.setSorting(order && column.get("name"), order, + {sortValue: column.get("sortValue")}); if (collection.mode == "client") { - if (!collection.fullCollection.comparator) { + if (collection.fullCollection.comparator == null) { collection.fullCollection.comparator = comparator; } collection.fullCollection.sort(); @@ -1888,26 +1944,24 @@ var HeaderCell = Backgrid.HeaderCell = Backbone.View.extend({ collection.sort(); } - this.collection.trigger("backgrid:sort", columnName, direction, comparator, this.collection); + this.collection.trigger("backgrid:sort", column, direction, comparator, + this.collection); }, - /** - Default comparator for Backbone.Collections. Sorts cids in ascending - order. The cids of the models are assumed to be in insertion order. + makeComparator: function (attr, order, func) { - @private - @param {*} left - @param {*} right - */ - _cidComparator: function (left, right) { - var lcid = left.cid, rcid = right.cid; - if (!_.isUndefined(lcid) && !_.isUndefined(rcid)) { - lcid = lcid.slice(1) * 1, rcid = rcid.slice(1) * 1; - if (lcid < rcid) return -1; - else if (lcid > rcid) return 1; - } + return function (left, right) { + // extract the values from the models + var l = func(left, attr), r = func(right, attr), t; + + // if descending order, swap left and right + if (order === 1) t = l, l = r, r = t; - return 0; + // compare as usual + if (l === r) return 0; + else if (l < r) return -1; + return 1; + }; }, /** @@ -1915,7 +1969,9 @@ var HeaderCell = Backgrid.HeaderCell = Backbone.View.extend({ */ render: function () { this.$el.empty(); - var $label = $("").text(this.column.get("label")).append(""); + var $label = $("").text(this.column.get("label")); + var sortable = Backgrid.callByNeed(this.column.get("sortable"), this.column, this.model); + if (sortable) $label.append(""); this.$el.append($label); this.delegateEvents(); return this; @@ -2259,6 +2315,9 @@ var Body = Backgrid.Body = Backbone.View.extend({ moveToNextCell: function (model, column, command) { var i = this.collection.indexOf(model); var j = this.columns.indexOf(column); + var cell, renderable, editable; + + this.rows[i].cells[j].exitEditMode(); if (command.moveUp() || command.moveDown() || command.moveLeft() || command.moveRight() || command.save()) { @@ -2267,7 +2326,12 @@ var Body = Backgrid.Body = Backbone.View.extend({ if (command.moveUp() || command.moveDown()) { var row = this.rows[i + (command.moveUp() ? -1 : 1)]; - if (row) row.cells[j].enterEditMode(); + if (row) { + cell = row.cells[j]; + if (Backgrid.callByNeed(cell.column.get("editable"), cell.column, model)) { + cell.enterEditMode(); + } + } } else if (command.moveLeft() || command.moveRight()) { var right = command.moveRight(); @@ -2276,16 +2340,16 @@ var Body = Backgrid.Body = Backbone.View.extend({ right ? offset++ : offset--) { var m = ~~(offset / l); var n = offset - m * l; - var cell = this.rows[m].cells[n]; - if (cell.column.get("renderable") && cell.column.get("editable")) { + cell = this.rows[m].cells[n]; + renderable = Backgrid.callByNeed(cell.column.get("renderable"), cell.column, cell.model); + editable = Backgrid.callByNeed(cell.column.get("editable"), cell.column, model); + if (renderable && editable) { cell.enterEditMode(); break; } } } } - - this.rows[i].cells[j].exitEditMode(); } }); /* diff --git a/UI/JsLibraries/backbone.backgrid.paginator.js b/UI/JsLibraries/backbone.backgrid.paginator.js index cceabca00..430a818f7 100644 --- a/UI/JsLibraries/backbone.backgrid.paginator.js +++ b/UI/JsLibraries/backbone.backgrid.paginator.js @@ -6,121 +6,257 @@ Licensed under the MIT @license. */ -(function ($, _, Backbone, Backgrid) { +(function (_, Backbone, Backgrid) { "use strict"; /** - Paginator is a Backgrid extension that renders a series of configurable - pagination handles. This extension is best used for splitting a large data - set across multiple pages. If the number of pages is larger then a - threshold, which is set to 10 by default, the page handles are rendered - within a sliding window, plus the fast forward, fast backward, previous and - next page handles. The fast forward, fast backward, previous and next page - handles can be turned off. - - @class Backgrid.Extension.Paginator + PageHandle is a class that renders the actual page handles and reacts to + click events for pagination. + + This class acts in two modes - control or discrete page handle modes. If + one of the `is*` flags is `true`, an instance of this class is under + control page handle mode. Setting a `pageIndex` to an instance of this + class under control mode has no effect and the correct page index will + always be inferred from the `is*` flag. Only one of the `is*` flags should + be set to `true` at a time. For example, an instance of this class cannot + simultaneously be a rewind control and a fast forward control. A `label` + and a `title` template or a string are required to be passed to the + constuctor under this mode. If a `title` template is provided, it __MUST__ + accept a parameter `label`. When the `label` is provided to the `title` + template function, its result will be used to render the generated anchor's + title attribute. + + If all of the `is*` flags is set to `false`, which is the default, an + instance of this class will be in discrete page handle mode. An instance + under this mode requires the `pageIndex` to be passed from the constructor + as an option and it __MUST__ be a 0-based index of the list of page numbers + to render. The constuctor will normalize the base to the same base the + underlying PageableCollection collection instance uses. A `label` is not + required under this mode, which will default to the equivalent 1-based page + index calculated from `pageIndex` and the underlying PageableCollection + instance. A provided `label` will still be honored however. The `title` + parameter is also not required under this mode, in which case the default + `title` template will be used. You are encouraged to provide your own + `title` template however if you wish to localize the title strings. + + If this page handle represents the current page, an `active` class will be + placed on the root list element. + + if this page handle is at the border of the list of pages, a `disabled` + class will be placed on the root list element. + + Only page handles that are neither `active` nor `disabled` will respond to + click events and triggers pagination. + + @class Backgrid.Extension.PageHandle */ - Backgrid.Extension.Paginator = Backbone.View.extend({ + var PageHandle = Backgrid.Extension.PageHandle = Backbone.View.extend({ /** @property */ - className: "backgrid-paginator", + tagName: "li", /** @property */ - windowSize: 10, + events: { + "click a": "changePage" + }, /** - @property {Object} fastForwardHandleLabels You can disable specific - handles by setting its value to `null`. + @property {string|function(Object.): string} title + The title to use for the `title` attribute of the generated page handle + anchor elements. It can be a string or an Underscore template function + that takes a mandatory `label` parameter. */ - fastForwardHandleLabels: { - first: "《", - prev: "〈", - next: "〉", - last: "》" - }, + title: _.template('Page <%- label %>'), - /** @property */ - template: _.template(''), + /** + @property {boolean} isRewind Whether this handle represents a rewind + control + */ + isRewind: false, - /** @property */ - events: { - "click a": "changePage" - }, + /** + @property {boolean} isBack Whether this handle represents a back + control + */ + isBack: false, + + /** + @property {boolean} isForward Whether this handle represents a forward + control + */ + isForward: false, + + /** + @property {boolean} isFastForward Whether this handle represents a fast + forward control + */ + isFastForward: false, /** Initializer. @param {Object} options @param {Backbone.Collection} options.collection - @param {boolean} [options.fastForwardHandleLabels] Whether to render fast forward buttons. + @param {number} pageIndex 0-based index of the page number this handle + handles. This parameter will be normalized to the base the underlying + PageableCollection uses. + @param {string} [options.label] If provided it is used to render the + anchor text, otherwise the normalized pageIndex will be used + instead. Required if any of the `is*` flags is set to `true`. + @param {string} [options.title] + @param {boolean} [options.isRewind=false] + @param {boolean} [options.isBack=false] + @param {boolean} [options.isForward=false] + @param {boolean} [options.isFastForward=false] */ initialize: function (options) { - Backgrid.requireOptions(options, ["collection"]); + Backbone.View.prototype.initialize.apply(this, arguments); var collection = this.collection; - var fullCollection = collection.fullCollection; - if (fullCollection) { - this.listenTo(fullCollection, "add", this.render); - this.listenTo(fullCollection, "remove", this.render); - this.listenTo(fullCollection, "reset", this.render); - } + var state = collection.state; + var currentPage = state.currentPage; + var firstPage = state.firstPage; + var lastPage = state.lastPage; + + _.extend(this, _.pick(options, + ["isRewind", "isBack", "isForward", "isFastForward"])); + + var pageIndex; + if (this.isRewind) pageIndex = firstPage; + else if (this.isBack) pageIndex = Math.max(firstPage, currentPage - 1); + else if (this.isForward) pageIndex = Math.min(lastPage, currentPage + 1); + else if (this.isFastForward) pageIndex = lastPage; else { - this.listenTo(collection, "add", this.render); - this.listenTo(collection, "remove", this.render); - this.listenTo(collection, "reset", this.render); + pageIndex = +options.pageIndex; + pageIndex = (firstPage ? pageIndex + 1 : pageIndex); } + this.pageIndex = pageIndex; + + if (((this.isRewind || this.isBack) && currentPage == firstPage) || + ((this.isForward || this.isFastForward) && currentPage == lastPage)) { + this.$el.addClass("disabled"); + } + else if (!(this.isRewind || + this.isBack || + this.isForward || + this.isFastForward) && + currentPage == pageIndex) { + this.$el.addClass("active"); + } + + this.label = (options.label || (firstPage ? pageIndex : pageIndex + 1)) + ''; + var title = options.title || this.title; + this.title = _.isFunction(title) ? title({label: this.label}) : title; }, /** - jQuery event handler for the page handlers. Goes to the right page upon - clicking. + Renders a clickable anchor element under a list item. + */ + render: function () { + this.$el.empty(); + var anchor = document.createElement("a"); + anchor.href = '#'; + if (this.title) anchor.title = this.title; + anchor.innerHTML = this.label; + this.el.appendChild(anchor); + this.delegateEvents(); + return this; + }, - @param {Event} e - */ + /** + jQuery click event handler. Goes to the page this PageHandle instance + represents. No-op if this page handle is currently active or disabled. + */ changePage: function (e) { e.preventDefault(); + var $el = this.$el; + if (!$el.hasClass("active") && !$el.hasClass("disabled")) { + this.collection.getPage(this.pageIndex); + } + return this; + } - var $li = $(e.target).parent(); - if (!$li.hasClass("active") && !$li.hasClass("disabled")) { - - var label = $(e.target).text(); - var ffLabels = this.fastForwardHandleLabels; - - var collection = this.collection; - - if (ffLabels) { - switch (label) { - case ffLabels.first: - collection.getFirstPage(); - return; - case ffLabels.prev: - collection.getPreviousPage(); - return; - case ffLabels.next: - collection.getNextPage(); - return; - case ffLabels.last: - collection.getLastPage(); - return; - } - } + }); + + /** + Paginator is a Backgrid extension that renders a series of configurable + pagination handles. This extension is best used for splitting a large data + set across multiple pages. If the number of pages is larger then a + threshold, which is set to 10 by default, the page handles are rendered + within a sliding window, plus the rewind, back, forward and fast forward + control handles. The individual control handles can be turned off. + + @class Backgrid.Extension.Paginator + */ + Backgrid.Extension.Paginator = Backbone.View.extend({ - var state = collection.state; - var pageIndex = +label; - collection.getPage(state.firstPage === 0 ? pageIndex - 1 : pageIndex); + /** @property */ + className: "backgrid-paginator", + + /** @property */ + windowSize: 10, + + /** + @property {Object.>} controls You can + disable specific control handles by omitting certain keys. + */ + controls: { + rewind: { + label: "《", + title: "First" + }, + back: { + label: "〈", + title: "Previous" + }, + forward: { + label: "〉", + title: "Next" + }, + fastForward: { + label: "》", + title: "Last" } }, /** - Internal method to create a list of page handle objects for the template - to render them. + @property {Backgrid.Extension.PageHandle} pageHandle. The PageHandle + class to use for rendering individual handles + */ + pageHandle: PageHandle, - @return {Array.} an array of page handle objects hashes - */ - makeHandles: function () { + /** @property */ + goBackFirstOnSort: true, - var handles = []; + /** + Initializer. + + @param {Object} options + @param {Backbone.Collection} options.collection + @param {boolean} [options.controls] + @param {boolean} [options.pageHandle=Backgrid.Extension.PageHandle] + @param {boolean} [options.goBackFirstOnSort=true] + */ + initialize: function (options) { + Backgrid.requireOptions(options, ["collection"]); + + this.controls = options.controls || this.controls; + this.pageHandle = options.pageHandle || this.pageHandle; + + var collection = this.collection; + this.listenTo(collection, "add", this.render); + this.listenTo(collection, "remove", this.render); + this.listenTo(collection, "reset", this.render); + if ((options.goBackFirstOnSort || this.goBackFirstOnSort) && + collection.fullCollection) { + this.listenTo(collection.fullCollection, "sort", function () { + collection.getFirstPage(); + }); + } + }, + + _calculateWindow: function () { var collection = this.collection; var state = collection.state; @@ -132,48 +268,44 @@ currentPage = firstPage ? currentPage - 1 : currentPage; var windowStart = Math.floor(currentPage / this.windowSize) * this.windowSize; var windowEnd = Math.min(lastPage + 1, windowStart + this.windowSize); + return [windowStart, windowEnd]; + }, - if (collection.mode !== "infinite") { - for (var i = windowStart; i < windowEnd; i++) { - handles.push({ - label: i + 1, - title: "No. " + (i + 1), - className: currentPage === i ? "active" : undefined - }); - } - } + /** + Creates a list of page handle objects for rendering. - var ffLabels = this.fastForwardHandleLabels; - if (ffLabels) { + @return {Array.} an array of page handle objects hashes + */ + makeHandles: function () { - if (ffLabels.prev) { - handles.unshift({ - label: ffLabels.prev, - className: collection.hasPrevious() ? void 0 : "disabled" - }); - } + var handles = []; + var collection = this.collection; - if (ffLabels.first) { - handles.unshift({ - label: ffLabels.first, - className: collection.hasPrevious() ? void 0 : "disabled" - }); - } + var window = this._calculateWindow(); + var winStart = window[0], winEnd = window[1]; - if (ffLabels.next) { - handles.push({ - label: ffLabels.next, - className: collection.hasNext() ? void 0 : "disabled" - }); - } + for (var i = winStart; i < winEnd; i++) { + handles.push(new PageHandle({ + collection: collection, + pageIndex: i + })); + } - if (ffLabels.last) { - handles.push({ - label: ffLabels.last, - className: collection.hasNext() ? void 0 : "disabled" - }); + var controls = this.controls; + _.each(["back", "rewind", "forward", "fastForward"], function (key) { + var value = controls[key]; + if (value) { + var handleCtorOpts = { + collection: collection, + title: value.title, + label: value.label + }; + handleCtorOpts["is" + key.slice(0, 1).toUpperCase() + key.slice(1)] = true; + var handle = new PageHandle(handleCtorOpts); + if (key == "rewind" || key == "back") handles.unshift(handle); + else handles.push(handle); } - } + }); return handles; }, @@ -184,15 +316,24 @@ render: function () { this.$el.empty(); - this.$el.append(this.template({ - handles: this.makeHandles() - })); + if (this.handles) { + for (var i = 0, l = this.handles.length; i < l; i++) { + this.handles[i].remove(); + } + } - this.delegateEvents(); + var handles = this.handles = this.makeHandles(); + + var ul = document.createElement("ul"); + for (var i = 0; i < handles.length; i++) { + ul.appendChild(handles[i].render().el); + } + + this.el.appendChild(ul); return this; } }); -}(jQuery, _, Backbone, Backgrid)); +}(_, Backbone, Backgrid)); diff --git a/UI/JsLibraries/backbone.pageable.js b/UI/JsLibraries/backbone.pageable.js index f63288064..23f0b3e45 100644 --- a/UI/JsLibraries/backbone.pageable.js +++ b/UI/JsLibraries/backbone.pageable.js @@ -1,5 +1,5 @@ /* - backbone-pageable 1.3.0 + backbone-pageable 1.3.1 http://github.com/wyuenho/backbone-pageable Copyright (c) 2013 Jimmy Yuen Ho Wong @@ -574,6 +574,17 @@ /** Change the page size of this collection. + Under most if not all circumstances, you should call this method to + change the page size of a pageable collection because it will keep the + pagination state sane. By default, the method will recalculate the + current page number to one that will retain the current page's models + when increasing the page size. When decreasing the page size, this method + will retain the last models to the current page that will fit into the + smaller page size. + + If `options.first` is true, changing the page size will also reset the + current page back to the first page instead of trying to be smart. + For server mode operations, changing the page size will trigger a #fetch and subsequently a `reset` event. @@ -586,6 +597,8 @@ @param {number} pageSize The new page size to set to #state. @param {Object} [options] {@link #fetch} options. + @param {boolean} [options.first=false] Reset the current page number to + the first page if `true`. @param {boolean} [options.fetch] If `true`, force a fetch in client mode. @throws {TypeError} If `pageSize` is not a finite integer. @@ -598,14 +611,24 @@ setPageSize: function (pageSize, options) { pageSize = finiteInt(pageSize, "pageSize"); - options = options || {}; + options = options || {first: false}; - this.state = this._checkState(_extend({}, this.state, { + var state = this.state; + var totalPages = ceil(state.totalRecords / pageSize); + var currentPage = max(state.firstPage, + floor(totalPages * + (state.firstPage ? + state.currentPage : + state.currentPage + 1) / + state.totalPages)); + + state = this.state = this._checkState(_extend({}, state, { pageSize: pageSize, - totalPages: ceil(this.state.totalRecords / pageSize) + currentPage: options.first ? state.firstPage : currentPage, + totalPages: totalPages })); - return this.getPage(this.state.currentPage, options); + return this.getPage(state.currentPage, _omit(options, ["first"])); }, /** @@ -992,13 +1015,14 @@ encouraged to override #parseState and #parseRecords instead. @param {Object} resp The deserialized response data from the server. + @param {Object} the options for the ajax request @return {Array.} An array of model objects */ - parse: function (resp) { - var newState = this.parseState(resp, _clone(this.queryParams), _clone(this.state)); + parse: function (resp, options) { + var newState = this.parseState(resp, _clone(this.queryParams), _clone(this.state), options); if (newState) this.state = this._checkState(_extend({}, this.state, newState)); - return this.parseRecords(resp); + return this.parseRecords(resp, options); }, /** @@ -1016,10 +1040,16 @@ `totalRecords` value is enough to trigger a full pagination state recalculation. - parseState: function (resp, queryParams, state) { + parseState: function (resp, queryParams, state, options) { return {totalRecords: resp.total_entries}; } + If you want to use header fields use: + + parseState: function (resp, queryParams, state, options) { + return {totalRecords: options.xhr.getResponseHeader("X-total")}; + } + This method __MUST__ return a new state object instead of directly modifying the #state object. The behavior of directly modifying #state is undefined. @@ -1027,10 +1057,12 @@ @param {Object} resp The deserialized response data from the server. @param {Object} queryParams A copy of #queryParams. @param {Object} state A copy of #state. + @param {Object} [options] The options passed through from + `parse`. (backbone >= 0.9.10 only) @return {Object} A new (partial) state object. */ - parseState: function (resp, queryParams, state) { + parseState: function (resp, queryParams, state, options) { if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) { var newState = _clone(state); @@ -1059,10 +1091,12 @@ response is returned directly. @param {Object} resp The deserialized response data from the server. + @param {Object} [options] The options passed through from the + `parse`. (backbone >= 0.9.10 only) @return {Array.} An array of model objects */ - parseRecords: function (resp) { + parseRecords: function (resp, options) { if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) { return resp[1]; } @@ -1138,7 +1172,7 @@ kvp = extraKvps[i]; v = kvp[1]; v = _isFunction(v) ? v.call(thisCopy) : v; - data[kvp[0]] = v; + if (v != null) data[kvp[0]] = v; } var fullCol = this.fullCollection, links = this.links; @@ -1212,11 +1246,11 @@ @param {string} [sortKey=this.state.sortKey] See `state.sortKey`. @param {number} [order=this.state.order] See `state.order`. + @param {(function(Backbone.Model, string): Object) | string} [sortValue] See #setSorting. See [Backbone.Collection.comparator](http://backbonejs.org/#Collection-comparator). */ - _makeComparator: function (sortKey, order) { - + _makeComparator: function (sortKey, order, sortValue) { var state = this.state; sortKey = sortKey || state.sortKey; @@ -1224,8 +1258,12 @@ if (!sortKey || !order) return; + if (!sortValue) sortValue = function (model, attr) { + return model.get(attr); + }; + return function (left, right) { - var l = left.get(sortKey), r = right.get(sortKey), t; + var l = sortValue(left, sortKey), r = sortValue(right, sortKey), t; if (order === 1) t = l, l = r, r = t; if (l === r) return 0; else if (l < r) return -1; @@ -1244,6 +1282,11 @@ `sortKey` to `null` removes the comparator from both the current page and the full collection. + If a `sortValue` function is given, it will be passed the `(model, + sortKey)` arguments and is used to extract a value from the model during + comparison sorts. If `sortValue` is not given, `model.get(sortKey)` is + used for sorting. + @chainable @param {string} sortKey See `state.sortKey`. @@ -1252,6 +1295,7 @@ @param {"server"|"client"} [options.side] By default, `"client"` if `mode` is `"client"`, `"server"` otherwise. @param {boolean} [options.full=true] + @param {(function(Backbone.Model, string): Object) | string} [options.sortValue] */ setSorting: function (sortKey, order, options) { @@ -1270,7 +1314,7 @@ options = _extend({side: mode == "client" ? mode : "server", full: true}, options); - var comparator = this._makeComparator(sortKey, order); + var comparator = this._makeComparator(sortKey, order, options.sortValue); var full = options.full, side = options.side;