diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..c13c5f6 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..6c43956 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,17 @@ +{ + + "env": { + "browser": true, + "node": true, + "mocha": true + }, + + "extends": "airbnb-base/legacy", + + "parser": "babel-eslint", + + "rules": { + "func-names": "off" + } + +} diff --git a/.gitignore b/.gitignore index 3c3629e..f06235c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +dist diff --git a/ajax-data-provider.js b/ajax-data-provider.js deleted file mode 100644 index 8c4063a..0000000 --- a/ajax-data-provider.js +++ /dev/null @@ -1,155 +0,0 @@ -(function ($) { - /*** - * A sample AJAX data store implementation. - * Right now, it's hooked up to load Hackernews stories, but can - * easily be extended to support any JSONP-compatible backend that accepts paging parameters. - */ - function AjaxDataProvider() { - // private - var perPage = 10; - var data = []; - var url = "http://192.168.60.167:3002/api.php"; - var timer = null; - var req = null; // ajax timer - - // events - var onDataLoading = new Slick.Event(); - var onDataLoaded = new Slick.Event(); - - function init() {} - - function isDataLoaded(from, to) { - for (var i = from; i <= to; i++) { - if (data[i] === undefined || data[i] === null) { - return false; - } - } - - return true; - } - - function clear() { - for (var key in data) { - delete data[key]; - } - } - - function getData() { - return data; - } - - function prepareData(from, to) { - if (req) { - req.abort(); - for (var i = req.fromPage; i <= req.toPage; i++) { - delete data[i * perPage]; - } - } - - if (from < 0) { - from = 0; - } - - if (data.length > 0) { - to = Math.min(to, data.length - 1); - } - - var fromPage = Math.floor(from / perPage); - var toPage = Math.floor(to / perPage); - - while (data[fromPage * perPage] !== undefined && fromPage < toPage) - fromPage++; - - while (data[toPage * perPage] !== undefined && fromPage < toPage) - toPage--; - - if (fromPage > toPage || ((fromPage == toPage) && data[fromPage * perPage] !== undefined)) { - // TODO: look-ahead - onDataLoaded.notify({from: from, to: to}); - return; - } - - var recStart = (fromPage * perPage); - var recCount = (((toPage - fromPage) * perPage) + perPage); - - url += "?page=" + recStart + "&count=" + recCount; - - if (timer !== null) { - clearTimeout(timer); - } - - timer = setTimeout(function () { - for (var i = fromPage; i <= toPage; i++) { - data[i * perPage] = null; // null indicates a 'timered but not available yet' - } - - onDataLoading.notify({from: from, to: to}); - - req = $.ajax({ - url: url, - type: 'get', - dataType: 'json', - cache: true, - success: function (resp, textStatus, xOptions) { - onSuccess(resp, recStart); - }, - error: function () { - onError(fromPage, toPage); - } - }); - - req.fromPage = fromPage; - req.toPage = toPage; - }, 50); - } - - function refresh(from, to) { - for (var i = from; i <= to; i++) { - delete data[i]; - } - - getData(from, to); - } - - function onError(fromPage, toPage) { - console.log("Error loading pages " + fromPage + " to " + toPage); - } - - function onSuccess(resp, start) { - var end = start; - if (resp.data.length > 0) { - end = start + resp.data.length; - data.length += Math.min(parseInt(resp.data.length, 10),1000); // limitation of the API - - for (var i = 0; i < resp.data.length; i++) { - var item = resp.data[i]; - data[start + i] = item; - } - } - - req = null; - - onDataLoaded.notify({from: start, to: end}); - } - - init(); - - return { - // properties - "getData": getData, - - // methods - "clear": clear, - "isDataLoaded": isDataLoaded, - "prepareData": prepareData, - "refresh": refresh, - - // events - "onDataLoading": onDataLoading, - "onDataLoaded": onDataLoaded - }; - } - - // Slick.RemoteDataProvider - $.extend(true, window, { Flow: { AjaxDataProvider: AjaxDataProvider }}); -})(jQuery); diff --git a/api.php b/examples/api.php similarity index 100% rename from api.php rename to examples/api.php diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..564e38f --- /dev/null +++ b/examples/index.html @@ -0,0 +1,138 @@ + + + + Listview Example + + + + + + + +
+
+
+
+
+
Affiliate Site
+ +
+
+
+
Impressions
+ +
+
+
+
Clicks
+ +
+
+
+
Leads
+ +
+
+
+
Sales
+ +
+
+
+
+
+
Name
+ +
+
+
+
#raw
+ +
+
+
+
#unique
+ +
+
+
+
Banner
+ +
+
+
+
#
+ +
+
+
+
Comission
+ +
+
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/grid.js b/grid.js deleted file mode 100644 index 6e21475..0000000 --- a/grid.js +++ /dev/null @@ -1,62 +0,0 @@ -function Grid(opts) { - this.el = opts.el; - if (this.el !== null && typeof this.el !== 'object') { - throw new Error("Grid Error: invalid element provided, element does not exist in the DOM."); - } - - this.opts = opts; - this.columns = opts.columns; - this.dataProvider = opts.dataProvider; - - this.init(); -} - -Grid.prototype.init = function() { -}; - -Grid.prototype.render = function() { - var gridPane = document.createElement('div'); - gridPane.className = 'grid-pane'; - gridPane.setAttribute('style', 'height: 500px'); - - var gridViewport = document.createElement('div'); - gridViewport.className = 'grid-viewport'; - gridViewport.setAttribute('style', 'height: 500px; overflow-y: scroll; overflow-x: hidden;'); - - var gridContent = document.createElement('div'); - gridContent.className = 'grid-content'; - gridContent.setAttribute('style', 'position: relative; height: 1000px;'); - - var gridHeader = document.createElement('div'); - gridHeader.className = 'grid-header'; - - var data = this.dataProvider.getData(); - var columns = this.columns; - - for (var i = 0, dataCount = data.length; i < dataCount; i++) { - var row = document.createElement('div'); - row.className = 'grid-row'; - row.setAttribute('style', 'position: absolute; top: ' + i * this.opts.rowHeight + 'px;'); - var rowData = data[i]; - - if (rowData === null || typeof rowData !== 'object') { - continue; - } - - for (var j = 0, columnCount = columns.length; j < columnCount; j++) { - var cell = document.createElement('div'); - cell.innerHTML = rowData[columns[j].id]; - cell.className = 'grid-cell ' + 'grid-cell-' + columns[j].id; - row.appendChild(cell); - } - - gridContent.appendChild(row); - } - - gridViewport.appendChild(gridContent); - gridPane.appendChild(gridViewport); - - this.el.addClass('grid') - .append(gridHeader) - .append(gridPane); -}; diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..b362848 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,74 @@ +const browserSync = require('browser-sync'); +const gulp = require('gulp'); +const sass = require('gulp-sass'); +const eslint = require('gulp-eslint'); +const plumber = require('gulp-plumber'); +const rollup = require('gulp-better-rollup'); +const commonjs = require('rollup-plugin-commonjs'); +const resolve = require('rollup-plugin-node-resolve'); +const babel = require('rollup-plugin-babel'); +const jasmine = require('gulp-jasmine'); + +gulp.task('js', () => { + gulp.src('./src/js/index.js') + .pipe(plumber()) + .pipe(rollup({ + plugins: [ + resolve({ jsnext: true, main: true }), + commonjs({ include: './src/js', extensions: ['.js'] }), + babel({ + presets: [['es2015', { modules: false }]], + exclude: 'node_modules/**', + plugins: ['external-helpers'], + externalHelpers: true + }) + ], + format: 'umd', + moduleName: 'Flow' + })) + .pipe(gulp.dest('./dist/js')) + .pipe(browserSync.stream()); +}); + +gulp.task('test', () => { + gulp.src('spec/**') + .pipe(plumber()) + .pipe(jasmine({ + config: 'spec/support/jasmine.json' + })); +}); + +gulp.task('eslint', function() { + return gulp.src('./src/js/*.js') + .pipe(plumber()) + .pipe(eslint()) + .pipe(eslint.format()); +}); + +gulp.task('sass', function() { + return gulp.src('./src/scss/*.scss') + .pipe(plumber()) + .pipe(sass()) + .pipe(gulp.dest('./dist/css/')) + .pipe(browserSync.stream()); +}); + +gulp.task('html', function() { + return gulp.src('./src/*.html') + .pipe(plumber()) + .pipe(gulp.dest('./dist/')) + .pipe(browserSync.stream()); +}); + +gulp.task('serve', ['html', 'eslint', 'js', 'sass'], function() { + browserSync.init({ + server: ['./dist', './examples', './'] + }); + + gulp.watch('./src/js/*.js', ['eslint', 'js']); + gulp.watch('./src/scss/*.scss', ['sass']); + gulp.watch('./src/*.html', ['html']); +}); + +gulp.task('default', ['serve']); + diff --git a/index.html b/index.html deleted file mode 100644 index 892cd2a..0000000 --- a/index.html +++ /dev/null @@ -1,109 +0,0 @@ - - - - Listview Example - - - - - - - - - - -
-
-
-
- - - - - - - - - - - - - - - - - diff --git a/package.json b/package.json index 8773fd2..906decd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,29 @@ "dependencies": { "bootstrap": "^3.3.7", "jqGrid": "^5.1.1", + "jquery": "^3.2.1", "slickgrid": "^2.3.3" + }, + "devDependencies": { + "babel-core": "^6.24.0", + "babel-eslint": "^7.2.1", + "babel-preset-es2015": "^6.24.0", + "babel-register": "^6.24.1", + "browser-sync": "^2.18.8", + "eslint-config-airbnb-base": "^11.1.3", + "eslint-config-import": "^0.13.0", + "eslint-plugin-import": "^2.2.0", + "gulp": "^3.9.1", + "gulp-babel": "^6.1.2", + "gulp-better-rollup": "^1.1.1", + "gulp-eslint": "^3.0.1", + "gulp-jasmine": "^2.4.2", + "gulp-plumber": "^1.1.0", + "gulp-sass": "^3.1.0", + "jquery": "^3.2.1", + "rollup": "^0.41.6", + "rollup-plugin-babel": "^2.7.1", + "rollup-plugin-commonjs": "^8.0.2", + "rollup-plugin-node-resolve": "^3.0.0" } } diff --git a/spec/emitter-spec.js b/spec/emitter-spec.js new file mode 100644 index 0000000..71c4220 --- /dev/null +++ b/spec/emitter-spec.js @@ -0,0 +1,58 @@ +import Emitter from '../src/js/emitter'; + +describe('Emitter', () => { + let emitter; + let result; + + beforeEach(() => { + emitter = new Emitter(); + result = []; + }); + + it(".on(eventName, fn)", () => { + registerEvents(); + emitEvents(); + + expect(result).toEqual([1, 2, 1, 2]); + }); + + it(".once(eventName, fn)", () => { + emitter.once('foo', (n) => { + result.push(n); + }); + + emitter.once('bar', (n) => { + result.push(n); + }); + + emitEvents(); + + expect(result).toEqual([1, 1]); + }); + + it(".off(eventName, fn)", () => { + registerEvents(); + emitter.off('foo'); + emitEvents(); + + expect(result).toEqual([1, 2]); + }); + + let registerEvents = () => { + emitter.on('foo', (n) => { + result.push(n); + }); + + emitter.on('bar', (n) => { + result.push(n); + }); + }; + + let emitEvents = () => { + emitter.emit('foo', 1); + emitter.emit('foo', 2); + emitter.emit('bar', 1); + emitter.emit('bar', 2); + }; +}); + diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 0000000..0920494 --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,12 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "**/*[sS]pec.js" + ], + "helpers": [ + "../node_modules/babel-register/lib/node.js", + "helpers/**/*.js" + ], + "stopSpecOnExpectationFailure": false, + "random": false +} diff --git a/src/js/ajax-data-provider.js b/src/js/ajax-data-provider.js new file mode 100644 index 0000000..2ece7d3 --- /dev/null +++ b/src/js/ajax-data-provider.js @@ -0,0 +1,123 @@ +/* eslint-disable */ +/* global $ */ +import Emitter from './emitter'; +import Pagination from './pagination'; + +class AjaxDataProvider extends Emitter { + constructor() { + super(); + + this.pageSize = 10; + this.page = 0; + this.totalCount = 0; + this.data = []; + + this.pagination = new Pagination({ + }); + + this.url = 'http://192.168.60.167:3002/api.php'; + this.refreshHints = {}; + if (this.data.length <= 0) { + this.prepareData(); + } + } + + setRefreshHints(hints) { + this.refreshHints = hints; + } + + getRefreshHints() { + return this.refreshHints; + } + + isDataLoaded(from, to) { + return this.data.slice(from, to) + .filter(i => i).length > 0; + } + + clearData() { + this.data.splice(this.page, (this.page * this.pageSize)); + } + + onError() { + throw new Error('Could not load page: ' + [ + this.page, this.page * this.pageSize + ].join('-')); + } + + onSuccess(resp) { + var from = this.page * this.pageSize; + var to = from; + if (resp.data.length > 0) { + to = from + resp.data.length; + this.data = this.data.concat(resp.data); + } + + this.emit('dataLoaded', { + from: from, to: to + }); + } + + prepareData() { + this.emit('dataLoading', { + from: this.page, to: this.page * this.pageSize + }); + + $.ajax({ + url: this.url, + type: 'get', + data: { page: this.page, per_page: this.pageSize }, + dataType: 'json', + cache: true, + success: this.onSuccess.bind(this), + error: this.onError.bind(this) + }); + } + + getData() { + return this.data; + } + + refresh(from, to) { + this.clearData(); + this.getData(from, to); + } + + getPagination() { + var totalPages = this.page * this.pageSize; + return { + page: this.page, + pageSize: this.pageSize, + totalCount: this.totalCount, + totalPages: this.totalPages + }; + } + + setPaginationOptions(args) { + var defaultPageSize = Math.max(0, Math.ceil(totalCount / pageSize) - 1); + + if (args.pageSize !== undefined) { + pageSize = args.pageSize; + page = pageSize ? Math.min(page, defaultPageSize) : 0; + } + + if (args.page !== undefined) { + this.page = Math.min(args.page, defaultPageSize); + } + + onPaginationUpdated.notify(getPagination(), null, self); + + this.refresh(); + } + + setPageSize(value) { + pageSize = value; + } + + getPageSize() { + return pageSize; + } +} + +export default AjaxDataProvider; + diff --git a/src/js/core.js b/src/js/core.js new file mode 100644 index 0000000..797fa11 --- /dev/null +++ b/src/js/core.js @@ -0,0 +1,322 @@ +/* eslint-disable */ + +/*** + * A global singleton editor lock. + * @class GlobalEditorLock + * @static + */ +export const GlobalEditorLock = new EditorLock(); + +export const keyCode = { + BACKSPACE: 8, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + INSERT: 45, + LEFT: 37, + PAGE_DOWN: 34, + PAGE_UP: 33, + RIGHT: 39, + TAB: 9, + UP: 38 +}; + + +/*** + * 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. + */ +export 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 + */ +export function NonDataItem() { + this.__nonDataRow = true; +} + + +/*** + * Information about a group of rows. + * @class Group + * @extends Slick.NonDataItem + * @constructor + */ +export 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 && + this.title === group.title; +}; + +/*** + * 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 + */ +export function GroupTotals() { + this.__groupTotals = true; + + /*** + * Parent Group. + * @param group + * @type {Group} + */ + this.group = null; + + /*** + * Whether the totals have been fully initialized / calculated. + * Will be set to false for lazy-calculated group totals. + * @param initialized + * @type {Boolean} + */ + this.initialized = false; +} + +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 + */ +export 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); + }; +} + diff --git a/src/js/data-provider.js b/src/js/data-provider.js new file mode 100644 index 0000000..c81952f --- /dev/null +++ b/src/js/data-provider.js @@ -0,0 +1,1133 @@ +/* eslint-disable */ +/*** + * 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 DataProvider(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 ? 0 : + (a.value > b.value ? 1 : -1) + ); + }, + predefinedValues: [], + aggregators: [], + aggregateEmpty: false, + aggregateCollapsed: false, + aggregateChildGroups: false, + collapsed: false, + displayTotalsRow: true, + lazyTotalsCalculation: false + }; + 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, dataView: self}; + } + + 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, l = idArray.length; i < l; i++) { + var row = rowsById[idArray[i]]; + if (row !== null) { + rows[rows.length] = row; + } + } + return rows; + } + + function mapRowsToIds(rowArray) { + var ids = []; + for (var i = 0, l = rowArray.length; i < l; 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) { + var item = rows[i]; + + // if this is a group row, make sure totals are calculated and update the title + if (item && item.__group && item.totals && !item.totals.initialized) { + var gi = groupingInfos[item.level]; + if (!gi.displayTotalsRow) { + calculateTotals(item.totals); + item.title = gi.formatter ? gi.formatter(item) : item.value; + } + } + // if this is a totals row, make sure it's calculated + else if (item && item.__groupTotals && !item.initialized) { + calculateTotals(item); + } + + return item; + } + + 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' group. + */ + 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' group. + */ + 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 k = 0, rl = rows.length; k < rl; k++) { + r = rows[k]; + 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 j = 0; j < groups.length; j++) { + group = groups[j]; + group.groups = extractGroups(group.rows, group); + } + } + + groups.sort(groupingInfos[level].comparer); + + return groups; + } + + function calculateTotals(totals) { + var group = totals.group; + var gi = groupingInfos[group.level]; + var isLeafLevel = (group.level == groupingInfos.length); + var agg, idx = gi.aggregators.length; + + if (!isLeafLevel && gi.aggregateChildGroups) { + // make sure all the subgroups are calculated + var i = group.groups.length; + while (i--) { + if (!group.groups[i].totals.initialized) { + calculateTotals(group.groups[i].totals); + } + } + } + + while (idx--) { + agg = gi.aggregators[idx]; + agg.init(); + if (!isLeafLevel && gi.aggregateChildGroups) { + gi.compiledAccumulators[idx].call(agg, group.groups); + } else { + gi.compiledAccumulators[idx].call(agg, group.rows); + } + agg.storeResult(totals); + } + totals.initialized = true; + } + + function addGroupTotals(group) { + var gi = groupingInfos[group.level]; + var totals = new Slick.GroupTotals(); + totals.group = group; + group.totals = totals; + if (!gi.lazyTotalsCalculation) { + calculateTotals(totals); + } + } + + function addTotals(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]; + + if (g.collapsed && !gi.aggregateCollapsed) { + continue; + } + + // Do a depth-first aggregation so that parent group aggregators can access subgroup totals. + if (g.groups) { + addTotals(g.groups, level + 1); + } + + if (gi.aggregators.length && ( + gi.aggregateEmpty || g.rows.length || (g.groups && g.groups.length))) { + addGroupTotals(g); + } + + g.collapsed = groupCollapsed ^ toggledGroups[g.groupingKey]; + g.title = gi.formatter ? gi.formatter(g) : g.value; + } + } + + 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 filterPath1 = "{ continue _coreloop; }$1"; + var filterPath2 = "{ _retval[_idx++] = $item$; continue _coreloop; }$1"; + // make some allowances for minification - there's only so far we can go with RegEx + var filterBody = filterInfo.body + .replace(/return false\s*([;}]|\}|$)/gi, filterPath1) + .replace(/return!1([;}]|\}|$)/gi, filterPath1) + .replace(/return true\s*([;}]|\}|$)/gi, filterPath2) + .replace(/return!0([;}]|\}|$)/gi, filterPath2) + .replace(/return ([^;}]+?)\s*([;}]|$)/gi, + "{ if ($1) { _retval[_idx++] = $item$; }; continue _coreloop; }$2"); + + // 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 filterPath1 = "{ continue _coreloop; }$1"; + var filterPath2 = "{ _cache[_i] = true;_retval[_idx++] = $item$; continue _coreloop; }$1"; + // make some allowances for minification - there's only so far we can go with RegEx + var filterBody = filterInfo.body + .replace(/return false\s*([;}]|\}|$)/gi, filterPath1) + .replace(/return!1([;}]|\}|$)/gi, filterPath1) + .replace(/return true\s*([;}]|\}|$)/gi, filterPath2) + .replace(/return!0([;}]|\}|$)/gi, filterPath2) + .replace(/return ([^;}]+?)\s*([;}]|$)/gi, + "{ if ((_cache[_i] = $1)) { _retval[_idx++] = $item$; }; continue _coreloop; }$2"); + + // 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) { + if (filteredItems.length === 0) { + pagenum = 0; + } else { + pagenum = Math.floor((filteredItems.length - 1) / 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]; + + // 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 + if ((groupingInfos.length && (eitherIsNonData = (item.__nonDataRow) || (r.__nonDataRow)) && + item.__group !== r.__group || + item.__group && !item.equals(r)) || + (eitherIsNonData && (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) { + addTotals(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, dataView: self}, null, self); + } + if (diff.length > 0) { + onRowsChanged.notify({rows: diff, dataView: self}, null, self); + } + } + + /*** + * Wires the grid and the DataProvider together to keep row selection tied to item ids. + * This is useful since, without it, the grid only knows about rows, so if the items + * move around, the same rows stay selected instead of the selection moving along + * with the items. + * + * NOTE: This doesn't work with cell selection model. + * + * @param grid {Slick.Grid} The grid to sync selection with. + * @param preserveHidden {Boolean} Whether to keep selected items that go out of the + * view due to them getting filtered out. + * @param preserveHiddenOnSelectionChange {Boolean} Whether to keep selected items + * that are currently out of the view (see preserveHidden) as selected when selection + * changes. + * @return {Slick.Event} An event that notifies when an internal list of selected row ids + * changes. This is useful since, in combination with the above two options, it allows + * access to the full list selected row ids, and not just the ones visible to the grid. + * @method syncGridSelection + */ + function syncGridSelection(grid, preserveHidden, preserveHiddenOnSelectionChange) { + var self = this; + var inHandler; + var selectedRowIds = self.mapRowsToIds(grid.getSelectedRows()); + var onSelectedRowIdsChanged = new Slick.Event(); + + function setSelectedRowIds(rowIds) { + if (selectedRowIds.join(",") == rowIds.join(",")) { + return; + } + + selectedRowIds = rowIds; + + onSelectedRowIdsChanged.notify({ + "grid": grid, + "ids": selectedRowIds, + "dataView": self + }, new Slick.EventData(), self); + } + + function update() { + if (selectedRowIds.length > 0) { + inHandler = true; + var selectedRows = self.mapIdsToRows(selectedRowIds); + if (!preserveHidden) { + setSelectedRowIds(self.mapRowsToIds(selectedRows)); + } + grid.setSelectedRows(selectedRows); + inHandler = false; + } + } + + grid.onSelectedRowsChanged.subscribe(function(e, args) { + if (inHandler) { return; } + var newSelectedRowIds = self.mapRowsToIds(grid.getSelectedRows()); + if (!preserveHiddenOnSelectionChange || !grid.getOptions().multiSelect) { + setSelectedRowIds(newSelectedRowIds); + } else { + // keep the ones that are hidden + var existing = $.grep(selectedRowIds, function(id) { return self.getRowById(id) === undefined; }); + // add the newly selected ones + setSelectedRowIds(existing.concat(newSelectedRowIds)); + } + }); + + this.onRowsChanged.subscribe(update); + + this.onRowCountChanged.subscribe(update); + + return onSelectedRowIdsChanged; + } + + 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); + } + + $.extend(this, { + // 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 !== "" && !isNaN(val)) { + 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 !== "" && !isNaN(val)) { + 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 !== "" && !isNaN(val)) { + 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 !== "" && !isNaN(val)) { + this.sum_ += parseFloat(val); + } + }; + + this.storeResult = function (groupTotals) { + if (!groupTotals.sum) { + groupTotals.sum = {}; + } + groupTotals.sum[this.field_] = this.sum_; + }; +} + +export const Aggregators = { + Avg: AvgAggregator, + Min: MinAggregator, + Max: MaxAggregator, + Sum: SumAggregator +}; + +export default DataProvider; + diff --git a/src/js/emitter.js b/src/js/emitter.js new file mode 100644 index 0000000..b5e887b --- /dev/null +++ b/src/js/emitter.js @@ -0,0 +1,122 @@ +/** + * A minimal event emitter implemention in ES6, api is fairly to similar to + * node api check node emitter documention for information. + * + * @example + * ``` + * var emitter = new Emitter(); + * emitter.on('foo', () => { + * console.log('foo triggered'); + * }); + * emitter.emit('foo'); + * ``` + */ + +class Emitter { + constructor() { + this.events = {}; + } + + /** + * Register an `event`. + * + * @param {String} eventName + * @param {Function} fn + * @return {Emitter} + */ + + on(eventName, fn) { + if (typeof this.events[eventName] === 'undefined') { + this.events[eventName] = []; + } + + this.events[eventName].push(fn); + return this; + } + + /** + * Remove an `event`. + * + * @param {String} eventName + * @param {Function} fn + * @return {Emitter} + */ + + off(eventName, fn) { + if (typeof eventName === 'undefined') { + this.events = {}; + } + + if (typeof fn === 'undefined') { + this.events[eventName] = []; + } + + if (typeof fn === 'function') { + let i; + let events = this.events[eventName]; + + for (i = 0; i < events.length; i += 1) { + if (events[i] === fn) { + events.splice(i, 1); + } + } + } + + return this; + } + + /** + * Register an `event` which is fired only once and removed. + * + * @param {Strint} eventName + * @param {Function} fn + * @return {Emitter} + */ + + once(eventName, fn) { + let self = this; + let cb = (data) => { + self.off(eventName, cb); + fn.call(this, data); + }; + + this.on(eventName, cb); + return this; + } + + /** + * Emit `event` with given arguments. + * + * @param {String} eventName + * @param {Mixed} args + * @return {Emitter} + */ + + emit(eventName, data) { + var i; + var events = this.events[eventName]; + + if (typeof events === 'undefined') { + return this; + } + + for (i = 0; i < events.length; i += 1) { + events[i].call(this, data); + } + + return this; + } + + /** + * An alias to `emit`. + * + * @see `emit` + */ + + trigger(eventName, data) { + return this.emit(eventName, data); + } +} + +export default Emitter; + diff --git a/src/js/event-data.js b/src/js/event-data.js new file mode 100644 index 0000000..6055042 --- /dev/null +++ b/src/js/event-data.js @@ -0,0 +1,47 @@ +/** + * 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() { + this.isPropagationStopped = false; + this.isImmediatePropagationStopped = false; +} + +/** + * Stops event from propagating up the DOM tree. + * @method stopPropagation + */ +EventData.prototype.stopPropagation = function () { + this.isPropagationStopped = true; +}; + +/** + * Returns whether stopPropagation was called on EventData.prototype.event object. + * @method isPropagationStopped + * @return {Boolean} + */ +EventData.prototype.isPropagationStopped = function () { + return this.isPropagationStopped; +}; + +/** + * Prevents the rest of the handlers from being executed. + * @method stopImmediatePropagation + */ +EventData.prototype.stopImmediatePropagation = function () { + this.isImmediatePropagationStopped = true; +}; + +/** + * Returns whether stopImmediatePropagation was called on EventData.prototype.event object.\ + * @method isImmediatePropagationStopped + * @return {Boolean} + */ +EventData.prototype.isImmediatePropagationStopped = function () { + return this.isImmediatePropagationStopped; +}; + +export default EventData; + diff --git a/src/js/event-handler.js b/src/js/event-handler.js new file mode 100644 index 0000000..4fde69c --- /dev/null +++ b/src/js/event-handler.js @@ -0,0 +1,41 @@ +/* eslint-disable */ +function EventHandler() { + this.handlers = []; +} + +EventHandler.prototype.on = function (event, handler) { + this.handlers.push({ + event: event, + handler: handler + }); + event.subscribe(handler); + + return this; +}; + +EventHandler.prototype.off = function (event, handler) { + var i = this.handlers.length; + while (i--) { + if (this.handlers[i].event === event && + this.handlers[i].handler === handler) { + this.handlers.splice(i, 1); + event.off(handler); + return; + } + } + + return this; +}; + +EventHandler.prototype.offAll = function () { + var i = this.handlers.length; + while (i--) { + this.handlers[i].event.off(this.handlers[i].handler); + } + this.handlers = []; + + return this; +}; + +export default EventHandler; + diff --git a/src/js/grid.js b/src/js/grid.js new file mode 100644 index 0000000..becbb5e --- /dev/null +++ b/src/js/grid.js @@ -0,0 +1,48 @@ +/* global $ */ +import Emitter from './emitter'; + +class Grid extends Emitter { + constructor(options) { + super(); + + this.el = options.el; + if (typeof this.el === 'undefined') { + throw new Error('Grid container must be valid dom element.'); + } + + this.columns = options.columns; + this.provider = options.dataProvider; + + this.render(); + } + + render() { + const { el, columns, provider } = this; + const data = provider.getData(); + const thead = $(''); + const dataLength = data.length; + + for (let i = 0; i < columns.length; i += 1) { + + let column = columns[i]; + const tr = $(''); + + for (let j = 0; j < dataLength; j += 1) { + let cell = data[j]; + let div = $('').text(cell); + + + // eslint-disable-next-line no-console + console.log(cell); + tr.append(div); + } + + thead.append(tr); + } + + el.append(thead); + } +} + +export default Grid; + diff --git a/src/js/index.js b/src/js/index.js new file mode 100644 index 0000000..972de88 --- /dev/null +++ b/src/js/index.js @@ -0,0 +1,14 @@ +/* eslint-disable */ +import { defined, extend } from './util'; +import Emitter from './emitter'; +import AjaxDataProvider from './ajax-data-provider'; +import Grid from './grid'; +import Pager from './pager'; + +export default { + Emitter, + AjaxDataProvider, + Grid, + Pager +}; + diff --git a/src/js/pager.js b/src/js/pager.js new file mode 100644 index 0000000..c54540f --- /dev/null +++ b/src/js/pager.js @@ -0,0 +1,140 @@ +/* eslint-disable */ +import Emitter from './emitter'; + +class Pager extends Emitter { + contructor(options) { + this.container = options.el; + this.provider = options.dataProvider; + this.el = $('
'); + this.status = null; + + this.render(); + this.registerEvents(); + this.pagination = this.provider.pagination; + this.updatePager(this.pagination); + } + + registerEvents() { + this.pagination.on('update', this.updatePager); + } + + getState() { + var locked = !Slick.GlobalEditorLock.commitCurrentEdit(); + var pager = dataProvider.getPagination(); + var lastPage = pager.totalPages - 1; + + return { + canGotoFirst: !locked && pager.pageSize !== 0 && pager.page > 0, + canGotoLast: !locked && pager.pageSize !== 0 && pager.page != lastPage, + canGotoPrev: !locked && pager.pageSize !== 0 && pager.page > 0, + canGotoNext: !locked && pager.pageSize !== 0 && pager.page < lastPage, + pager: pager + }; + } + + setPageSize(n) { + this.provider.setRefreshHints({ updateFilter: false }); + var page = parseInt(n, 10); + this.pagination.setPageSize(n); + } + + gotoFirst() { + if (getState().canGotoFirst) { + dataProvider.setPageSize(0); + } + } + + gotoLast() { + var state = getState(); + if (state.canGotoLast) { + dataProvider.setPageSize(state.pager.totalPages - 1); + } + } + + gotoPrev() { + var state = getState(); + if (state.canGotoPrev) { + dataProvider.setPageSize(state.pager.page - 1); + } + } + + gotoNext() { + var state = getState(); + if (state.canGotoNext) { + dataProvider.setPageSize(state.pager.page + 1); + } + } + + render() { + const { el } = this; + const nav = $('