From 9ab618855fcc79baf1cf97b714f78a289b5f633d Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Wed, 21 Feb 2018 19:01:04 +0530 Subject: [PATCH] [feature] Inline Filters --- dist/frappe-datatable.cjs.js | 728 ++++++++++++++++++++++++++++++++--- dist/frappe-datatable.css | 17 +- dist/frappe-datatable.js | 728 ++++++++++++++++++++++++++++++++--- index.html | 1 + package.json | 3 + rollup.config.js | 4 + src/cellmanager.js | 32 +- src/columnmanager.js | 64 ++- src/datamanager.js | 20 + src/defaults.js | 3 +- src/keyboard.js | 5 +- src/rowmanager.js | 15 + src/style.css | 17 +- src/style.js | 2 +- src/utils.js | 42 +- yarn.lock | 46 ++- 16 files changed, 1568 insertions(+), 159 deletions(-) diff --git a/dist/frappe-datatable.cjs.js b/dist/frappe-datatable.cjs.js index e89047f..1751590 100644 --- a/dist/frappe-datatable.cjs.js +++ b/dist/frappe-datatable.cjs.js @@ -177,6 +177,548 @@ $.scrollTop = function scrollTop(element, pixels) { }); }; +/** + * Checks if `value` is the + * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) + * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an object, else `false`. + * @example + * + * _.isObject({}); + * // => true + * + * _.isObject([1, 2, 3]); + * // => true + * + * _.isObject(_.noop); + * // => true + * + * _.isObject(null); + * // => false + */ +function isObject(value) { + var type = typeof value; + return value != null && (type == 'object' || type == 'function'); +} + +var isObject_1 = isObject; + +var commonjsGlobal = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + +/** Detect free variable `global` from Node.js. */ +var freeGlobal = typeof commonjsGlobal == 'object' && commonjsGlobal && commonjsGlobal.Object === Object && commonjsGlobal; + +var _freeGlobal = freeGlobal; + +/** Detect free variable `self`. */ +var freeSelf = typeof self == 'object' && self && self.Object === Object && self; + +/** Used as a reference to the global object. */ +var root = _freeGlobal || freeSelf || Function('return this')(); + +var _root = root; + +/** + * Gets the timestamp of the number of milliseconds that have elapsed since + * the Unix epoch (1 January 1970 00:00:00 UTC). + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Date + * @returns {number} Returns the timestamp. + * @example + * + * _.defer(function(stamp) { + * console.log(_.now() - stamp); + * }, _.now()); + * // => Logs the number of milliseconds it took for the deferred invocation. + */ +var now = function() { + return _root.Date.now(); +}; + +var now_1 = now; + +/** Built-in value references. */ +var Symbol = _root.Symbol; + +var _Symbol = Symbol; + +/** Used for built-in method references. */ +var objectProto = Object.prototype; + +/** Used to check objects for own properties. */ +var hasOwnProperty = objectProto.hasOwnProperty; + +/** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */ +var nativeObjectToString = objectProto.toString; + +/** Built-in value references. */ +var symToStringTag = _Symbol ? _Symbol.toStringTag : undefined; + +/** + * A specialized version of `baseGetTag` which ignores `Symbol.toStringTag` values. + * + * @private + * @param {*} value The value to query. + * @returns {string} Returns the raw `toStringTag`. + */ +function getRawTag(value) { + var isOwn = hasOwnProperty.call(value, symToStringTag), + tag = value[symToStringTag]; + + try { + value[symToStringTag] = undefined; + var unmasked = true; + } catch (e) {} + + var result = nativeObjectToString.call(value); + if (unmasked) { + if (isOwn) { + value[symToStringTag] = tag; + } else { + delete value[symToStringTag]; + } + } + return result; +} + +var _getRawTag = getRawTag; + +/** Used for built-in method references. */ +var objectProto$1 = Object.prototype; + +/** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */ +var nativeObjectToString$1 = objectProto$1.toString; + +/** + * Converts `value` to a string using `Object.prototype.toString`. + * + * @private + * @param {*} value The value to convert. + * @returns {string} Returns the converted string. + */ +function objectToString(value) { + return nativeObjectToString$1.call(value); +} + +var _objectToString = objectToString; + +/** `Object#toString` result references. */ +var nullTag = '[object Null]'; +var undefinedTag = '[object Undefined]'; + +/** Built-in value references. */ +var symToStringTag$1 = _Symbol ? _Symbol.toStringTag : undefined; + +/** + * The base implementation of `getTag` without fallbacks for buggy environments. + * + * @private + * @param {*} value The value to query. + * @returns {string} Returns the `toStringTag`. + */ +function baseGetTag(value) { + if (value == null) { + return value === undefined ? undefinedTag : nullTag; + } + return (symToStringTag$1 && symToStringTag$1 in Object(value)) + ? _getRawTag(value) + : _objectToString(value); +} + +var _baseGetTag = baseGetTag; + +/** + * Checks if `value` is object-like. A value is object-like if it's not `null` + * and has a `typeof` result of "object". + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is object-like, else `false`. + * @example + * + * _.isObjectLike({}); + * // => true + * + * _.isObjectLike([1, 2, 3]); + * // => true + * + * _.isObjectLike(_.noop); + * // => false + * + * _.isObjectLike(null); + * // => false + */ +function isObjectLike(value) { + return value != null && typeof value == 'object'; +} + +var isObjectLike_1 = isObjectLike; + +/** `Object#toString` result references. */ +var symbolTag = '[object Symbol]'; + +/** + * Checks if `value` is classified as a `Symbol` primitive or object. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a symbol, else `false`. + * @example + * + * _.isSymbol(Symbol.iterator); + * // => true + * + * _.isSymbol('abc'); + * // => false + */ +function isSymbol(value) { + return typeof value == 'symbol' || + (isObjectLike_1(value) && _baseGetTag(value) == symbolTag); +} + +var isSymbol_1 = isSymbol; + +/** Used as references for various `Number` constants. */ +var NAN = 0 / 0; + +/** Used to match leading and trailing whitespace. */ +var reTrim = /^\s+|\s+$/g; + +/** Used to detect bad signed hexadecimal string values. */ +var reIsBadHex = /^[-+]0x[0-9a-f]+$/i; + +/** Used to detect binary string values. */ +var reIsBinary = /^0b[01]+$/i; + +/** Used to detect octal string values. */ +var reIsOctal = /^0o[0-7]+$/i; + +/** Built-in method references without a dependency on `root`. */ +var freeParseInt = parseInt; + +/** + * Converts `value` to a number. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to process. + * @returns {number} Returns the number. + * @example + * + * _.toNumber(3.2); + * // => 3.2 + * + * _.toNumber(Number.MIN_VALUE); + * // => 5e-324 + * + * _.toNumber(Infinity); + * // => Infinity + * + * _.toNumber('3.2'); + * // => 3.2 + */ +function toNumber(value) { + if (typeof value == 'number') { + return value; + } + if (isSymbol_1(value)) { + return NAN; + } + if (isObject_1(value)) { + var other = typeof value.valueOf == 'function' ? value.valueOf() : value; + value = isObject_1(other) ? (other + '') : other; + } + if (typeof value != 'string') { + return value === 0 ? value : +value; + } + value = value.replace(reTrim, ''); + var isBinary = reIsBinary.test(value); + return (isBinary || reIsOctal.test(value)) + ? freeParseInt(value.slice(2), isBinary ? 2 : 8) + : (reIsBadHex.test(value) ? NAN : +value); +} + +var toNumber_1 = toNumber; + +/** Error message constants. */ +var FUNC_ERROR_TEXT = 'Expected a function'; + +/* Built-in method references for those with the same name as other `lodash` methods. */ +var nativeMax = Math.max; +var nativeMin = Math.min; + +/** + * Creates a debounced function that delays invoking `func` until after `wait` + * milliseconds have elapsed since the last time the debounced function was + * invoked. The debounced function comes with a `cancel` method to cancel + * delayed `func` invocations and a `flush` method to immediately invoke them. + * Provide `options` to indicate whether `func` should be invoked on the + * leading and/or trailing edge of the `wait` timeout. The `func` is invoked + * with the last arguments provided to the debounced function. Subsequent + * calls to the debounced function return the result of the last `func` + * invocation. + * + * **Note:** If `leading` and `trailing` options are `true`, `func` is + * invoked on the trailing edge of the timeout only if the debounced function + * is invoked more than once during the `wait` timeout. + * + * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred + * until to the next tick, similar to `setTimeout` with a timeout of `0`. + * + * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) + * for details over the differences between `_.debounce` and `_.throttle`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to debounce. + * @param {number} [wait=0] The number of milliseconds to delay. + * @param {Object} [options={}] The options object. + * @param {boolean} [options.leading=false] + * Specify invoking on the leading edge of the timeout. + * @param {number} [options.maxWait] + * The maximum time `func` is allowed to be delayed before it's invoked. + * @param {boolean} [options.trailing=true] + * Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns the new debounced function. + * @example + * + * // Avoid costly calculations while the window size is in flux. + * jQuery(window).on('resize', _.debounce(calculateLayout, 150)); + * + * // Invoke `sendMail` when clicked, debouncing subsequent calls. + * jQuery(element).on('click', _.debounce(sendMail, 300, { + * 'leading': true, + * 'trailing': false + * })); + * + * // Ensure `batchLog` is invoked once after 1 second of debounced calls. + * var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 }); + * var source = new EventSource('/stream'); + * jQuery(source).on('message', debounced); + * + * // Cancel the trailing debounced invocation. + * jQuery(window).on('popstate', debounced.cancel); + */ +function debounce(func, wait, options) { + var lastArgs, + lastThis, + maxWait, + result, + timerId, + lastCallTime, + lastInvokeTime = 0, + leading = false, + maxing = false, + trailing = true; + + if (typeof func != 'function') { + throw new TypeError(FUNC_ERROR_TEXT); + } + wait = toNumber_1(wait) || 0; + if (isObject_1(options)) { + leading = !!options.leading; + maxing = 'maxWait' in options; + maxWait = maxing ? nativeMax(toNumber_1(options.maxWait) || 0, wait) : maxWait; + trailing = 'trailing' in options ? !!options.trailing : trailing; + } + + function invokeFunc(time) { + var args = lastArgs, + thisArg = lastThis; + + lastArgs = lastThis = undefined; + lastInvokeTime = time; + result = func.apply(thisArg, args); + return result; + } + + function leadingEdge(time) { + // Reset any `maxWait` timer. + lastInvokeTime = time; + // Start the timer for the trailing edge. + timerId = setTimeout(timerExpired, wait); + // Invoke the leading edge. + return leading ? invokeFunc(time) : result; + } + + function remainingWait(time) { + var timeSinceLastCall = time - lastCallTime, + timeSinceLastInvoke = time - lastInvokeTime, + timeWaiting = wait - timeSinceLastCall; + + return maxing + ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke) + : timeWaiting; + } + + function shouldInvoke(time) { + var timeSinceLastCall = time - lastCallTime, + timeSinceLastInvoke = time - lastInvokeTime; + + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge, or we've hit the `maxWait` limit. + return (lastCallTime === undefined || (timeSinceLastCall >= wait) || + (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait)); + } + + function timerExpired() { + var time = now_1(); + if (shouldInvoke(time)) { + return trailingEdge(time); + } + // Restart the timer. + timerId = setTimeout(timerExpired, remainingWait(time)); + } + + function trailingEdge(time) { + timerId = undefined; + + // Only invoke if we have `lastArgs` which means `func` has been + // debounced at least once. + if (trailing && lastArgs) { + return invokeFunc(time); + } + lastArgs = lastThis = undefined; + return result; + } + + function cancel() { + if (timerId !== undefined) { + clearTimeout(timerId); + } + lastInvokeTime = 0; + lastArgs = lastCallTime = lastThis = timerId = undefined; + } + + function flush() { + return timerId === undefined ? result : trailingEdge(now_1()); + } + + function debounced() { + var time = now_1(), + isInvoking = shouldInvoke(time); + + lastArgs = arguments; + lastThis = this; + lastCallTime = time; + + if (isInvoking) { + if (timerId === undefined) { + return leadingEdge(lastCallTime); + } + if (maxing) { + // Handle invocations in a tight loop. + timerId = setTimeout(timerExpired, wait); + return invokeFunc(lastCallTime); + } + } + if (timerId === undefined) { + timerId = setTimeout(timerExpired, wait); + } + return result; + } + debounced.cancel = cancel; + debounced.flush = flush; + return debounced; +} + +var debounce_1 = debounce; + +/** Error message constants. */ +var FUNC_ERROR_TEXT$1 = 'Expected a function'; + +/** + * Creates a throttled function that only invokes `func` at most once per + * every `wait` milliseconds. The throttled function comes with a `cancel` + * method to cancel delayed `func` invocations and a `flush` method to + * immediately invoke them. Provide `options` to indicate whether `func` + * should be invoked on the leading and/or trailing edge of the `wait` + * timeout. The `func` is invoked with the last arguments provided to the + * throttled function. Subsequent calls to the throttled function return the + * result of the last `func` invocation. + * + * **Note:** If `leading` and `trailing` options are `true`, `func` is + * invoked on the trailing edge of the timeout only if the throttled function + * is invoked more than once during the `wait` timeout. + * + * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred + * until to the next tick, similar to `setTimeout` with a timeout of `0`. + * + * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) + * for details over the differences between `_.throttle` and `_.debounce`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to throttle. + * @param {number} [wait=0] The number of milliseconds to throttle invocations to. + * @param {Object} [options={}] The options object. + * @param {boolean} [options.leading=true] + * Specify invoking on the leading edge of the timeout. + * @param {boolean} [options.trailing=true] + * Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns the new throttled function. + * @example + * + * // Avoid excessively updating the position while scrolling. + * jQuery(window).on('scroll', _.throttle(updatePosition, 100)); + * + * // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes. + * var throttled = _.throttle(renewToken, 300000, { 'trailing': false }); + * jQuery(element).on('click', throttled); + * + * // Cancel the trailing throttled invocation. + * jQuery(window).on('popstate', throttled.cancel); + */ +function throttle(func, wait, options) { + var leading = true, + trailing = true; + + if (typeof func != 'function') { + throw new TypeError(FUNC_ERROR_TEXT$1); + } + if (isObject_1(options)) { + leading = 'leading' in options ? !!options.leading : leading; + trailing = 'trailing' in options ? !!options.trailing : trailing; + } + return debounce_1(func, wait, { + 'leading': leading, + 'maxWait': wait, + 'trailing': trailing + }); +} + +var throttle_1 = throttle; + function camelCaseToDash(str) { return str.replace(/([A-Z])/g, (g) => `-${g[0].toLowerCase()}`); } @@ -269,47 +811,16 @@ function isNumeric(val) { return !isNaN(val); } -// https://stackoverflow.com/a/27078401 -function throttle(func, wait, options) { - var context, args, result; - var timeout = null; - var previous = 0; - if (!options) options = {}; +let throttle$1 = throttle_1; - let later = function () { - previous = options.leading === false ? 0 : Date.now(); - timeout = null; - result = func.apply(context, args); - if (!timeout) context = args = null; - }; - - return function () { - var now = Date.now(); - if (!previous && options.leading === false) previous = now; - let remaining = wait - (now - previous); - context = this; - args = arguments; - if (remaining <= 0 || remaining > wait) { - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - previous = now; - result = func.apply(context, args); - if (!timeout) context = args = null; - } else if (!timeout && options.trailing !== false) { - timeout = setTimeout(later, remaining); - } - return result; - }; -} +let debounce$2 = debounce_1; function promisify(fn, context = null) { return (...args) => { return new Promise(resolve => { setTimeout(() => { - fn.apply(context, args); - resolve('done', fn.name); + const out = fn.apply(context, args); + resolve(out); }, 0); }); }; @@ -335,6 +846,7 @@ class DataManager { this.sortRows = promisify(this.sortRows, this); this.switchColumn = promisify(this.switchColumn, this); this.removeColumn = promisify(this.removeColumn, this); + this.filterRows = promisify(this.filterRows, this); } init(data) { @@ -716,6 +1228,25 @@ class DataManager { return column; } + filterRows(keyword, colIndex) { + let rowsToHide = []; + let rowsToShow = []; + const cells = this.rows.map(row => row[colIndex]); + + cells.forEach(cell => { + const hay = cell.content.toLowerCase(); + const needle = (keyword || '').toLowerCase(); + + if (!needle || hay.includes(needle)) { + rowsToShow.push(cell.rowIndex); + } else { + rowsToHide.push(cell.rowIndex); + } + }); + + return {rowsToHide, rowsToShow}; + } + getRowCount() { return this.rowCount; } @@ -839,7 +1370,19 @@ class ColumnManager { if (!$('.data-table-col', this.header)) { // insert html - $('thead', this.header).innerHTML = this.rowmanager.getRowHTML(columns, { isHeader: 1 }); + + let html = this.rowmanager.getRowHTML(columns, { isHeader: 1 }); + if (this.options.enableInlineFilters) { + html += this.rowmanager.getRowHTML(columns, { isFilter: 1 }); + } + + $('thead', this.header).innerHTML = html; + + this.$filterRow = $('.data-table-row[data-is-filter]', this.header); + // hide filter row immediately, so it doesn't disturb layout + $.style(this.$filterRow, { + display: 'none' + }); } else { // refresh dom state const $cols = $.each('.data-table-col', this.header); @@ -872,6 +1415,7 @@ class ColumnManager { this.bindDropdown(); this.bindResizeColumn(); this.bindMoveColumn(); + this.bindFilter(); } bindDropdown() { @@ -1073,6 +1617,51 @@ class ColumnManager { }); } + toggleFilter() { + this.isFilterShown = this.isFilterShown || false; + + if (this.isFilterShown) { + $.style(this.$filterRow, { + display: 'none' + }); + } else { + $.style(this.$filterRow, { + display: '' + }); + } + + this.isFilterShown = !this.isFilterShown; + this.style.setBodyStyle(); + } + + focusFilter(colIndex) { + if (!this.isFilterShown) return; + + const $filterInput = $(`[data-col-index="${colIndex}"] .data-table-filter`, this.$filterRow); + $filterInput.focus(); + } + + bindFilter() { + const handler = e => { + const $filterCell = $.closest('.data-table-col', e.target); + const { colIndex } = $.data($filterCell); + const keyword = e.target.value; + + this.datamanager.filterRows(keyword, colIndex) + .then(({ rowsToHide, rowsToShow }) => { + rowsToHide.map(rowIndex => { + const $tr = $(`.data-table-row[data-row-index="${rowIndex}"]`, this.bodyScrollable); + $tr.classList.add('hide'); + }); + rowsToShow.map(rowIndex => { + const $tr = $(`.data-table-row[data-row-index="${rowIndex}"]`, this.bodyScrollable); + $tr.classList.remove('hide'); + }); + }); + }; + $.on(this.header, 'keydown', '.data-table-filter', debounce$2(handler, 300)); + } + sortRows(colIndex, sortOrder) { return this.datamanager.sortRows(colIndex, sortOrder); } @@ -1104,7 +1693,7 @@ class ColumnManager { setColumnHeaderWidth(colIndex) { colIndex = +colIndex; this.$columnMap = this.$columnMap || []; - const selector = `[data-col-index="${colIndex}"][data-is-header] .content`; + const selector = `.data-table-header [data-col-index="${colIndex}"] .content`; const { width } = this.getColumn(colIndex); let $column = this.$columnMap[colIndex]; @@ -1261,6 +1850,14 @@ class CellManager { this.keyboard.on('esc', () => { this.deactivateEditing(); }); + + this.keyboard.on('ctrl+f', (e) => { + const $cell = $.closest('.data-table-col', e.target); + let { colIndex } = $.data($cell); + + this.activateFilter(colIndex); + return true; + }); } bindKeyboardSelection() { @@ -1309,7 +1906,7 @@ class CellManager { this.selectArea($(e.delegatedTarget)); }; - $.on(this.bodyScrollable, 'mousemove', '.data-table-col', throttle(selectArea, 50)); + $.on(this.bodyScrollable, 'mousemove', '.data-table-col', throttle$1(selectArea, 50)); } focusCell($cell, { skipClearSelection = 0 } = {}) { @@ -1615,6 +2212,16 @@ class CellManager { copyTextToClipboard(values); } + activateFilter(colIndex) { + this.columnmanager.toggleFilter(); + this.columnmanager.focusFilter(colIndex); + + if (!this.columnmanager.isFilterShown) { + // put focus back on cell + this.$focusedCell.focus(); + } + } + updateCell(colIndex, rowIndex, value) { const cell = this.datamanager.updateCell(colIndex, rowIndex, { content: value @@ -1699,11 +2306,12 @@ class CellManager { } getCellHTML(cell) { - const { rowIndex, colIndex, isHeader } = cell; + const { rowIndex, colIndex, isHeader, isFilter } = cell; const dataAttr = makeDataAttributeString({ rowIndex, colIndex, - isHeader + isHeader, + isFilter }); return ` @@ -1728,7 +2336,12 @@ class CellManager { const hasDropdown = isHeader && cell.dropdown !== false; const dropdown = hasDropdown ? `
${getDropdownHTML()}
` : ''; - const contentHTML = (!cell.isHeader && cell.column.format) ? cell.column.format(cell.content) : cell.content; + let contentHTML; + if (cell.isHeader || cell.isFilter || !cell.column.format) { + contentHTML = cell.content; + } else { + contentHTML = cell.column.format(cell.content); + } return `
@@ -1743,7 +2356,7 @@ class CellManager { getEditCellHTML() { return ` -
+
`; } @@ -1938,12 +2551,27 @@ class RowManager { getRowHTML(row, props) { const dataAttr = makeDataAttributeString(props); + if (props.isFilter) { + row = row.map(cell => (Object.assign(cell, { + content: this.getFilterInput({ colIndex: cell.colIndex }), + format: value => value, + isFilter: 1, + isHeader: undefined, + editable: false + }))); + } + return ` ${row.map(cell => this.cellmanager.getCellHTML(cell)).join('')} `; } + + getFilterInput(props) { + const dataAttr = makeDataAttributeString(props); + return ``; + } } class BodyRenderer { @@ -2064,7 +2692,7 @@ class Style { bindResizeWindow() { if (this.options.layout === 'fluid') { - $.on(window, 'resize', throttle(() => { + $.on(window, 'resize', throttle$1(() => { this.distributeRemainingWidth(); this.refreshColumnWidth(); this.setBodyStyle(); @@ -2136,7 +2764,7 @@ class Style { } setupMinWidth() { - $.each('.data-table-col', this.header).map(col => { + $.each('.data-table-col[data-is-header]', this.header).map(col => { const width = $.style($('.content', col), 'width'); const { colIndex @@ -2273,7 +2901,8 @@ const KEYCODES = { 40: 'down', 9: 'tab', 27: 'esc', - 67: 'c' + 67: 'c', + 70: 'f' }; class Keyboard { @@ -2297,7 +2926,7 @@ class Keyboard { if (listeners && listeners.length > 0) { for (let listener of listeners) { - const preventBubbling = listener(); + const preventBubbling = listener(e); if (preventBubbling === undefined || preventBubbling === true) { e.preventDefault(); } @@ -2363,7 +2992,8 @@ var DEFAULT_OPTIONS = { enableLogs: false, layout: 'fixed', // fixed, fluid noDataMessage: 'No Data', - cellHeight: null + cellHeight: null, + enableInlineFilters: false }; class DataTable { @@ -2537,14 +3167,14 @@ var version = "0.0.2"; var description = "A modern datatable library for the web"; var main = "dist/frappe-datatable.cjs.js"; var scripts = {"start":"npm run dev","build":"rollup -c","dev":"rollup -c -w","test":"mocha --compilers js:babel-core/register --colors ./test/*.spec.js","test:watch":"mocha --compilers js:babel-core/register --colors -w ./test/*.spec.js"}; -var devDependencies = {"chai":"3.5.0","cssnano":"^3.10.0","deepmerge":"^2.0.1","eslint":"3.19.0","eslint-loader":"1.7.1","mocha":"3.3.0","postcss-cssnext":"^3.1.0","postcss-nested":"^3.0.0","precss":"^3.1.0","rollup-plugin-json":"^2.3.0","rollup-plugin-postcss":"^1.2.8","rollup-plugin-uglify":"^3.0.0"}; +var devDependencies = {"chai":"3.5.0","cssnano":"^3.10.0","deepmerge":"^2.0.1","eslint":"3.19.0","eslint-loader":"1.7.1","mocha":"3.3.0","postcss-cssnext":"^3.1.0","postcss-nested":"^3.0.0","precss":"^3.1.0","rollup-plugin-commonjs":"^8.3.0","rollup-plugin-json":"^2.3.0","rollup-plugin-node-resolve":"^3.0.3","rollup-plugin-postcss":"^1.2.8","rollup-plugin-uglify":"^3.0.0"}; var repository = {"type":"git","url":"https://github.com/frappe/datatable.git"}; var keywords = ["datatable","data","grid","table"]; var author = "Faris Ansari"; var license = "MIT"; var bugs = {"url":"https://github.com/frappe/datatable/issues"}; var homepage = "https://frappe.github.io/datatable"; -var dependencies = {"clusterize.js":"^0.18.0","sortablejs":"^1.7.0"}; +var dependencies = {"clusterize.js":"^0.18.0","lodash":"^4.17.5","sortablejs":"^1.7.0"}; var packageJson = { name: name, version: version, diff --git a/dist/frappe-datatable.css b/dist/frappe-datatable.css index 69ec17f..90ab6d1 100644 --- a/dist/frappe-datatable.css +++ b/dist/frappe-datatable.css @@ -24,6 +24,12 @@ padding: 0; } +.data-table .input-style { + outline: none; + width: 100%; + border: none; + } + .data-table *, .data-table *:focus { outline: none; border-radius: 0px; @@ -80,6 +86,10 @@ opacity: 0.5; } +.data-table .hide { + display: none; + } + .body-scrollable { max-height: 500px; overflow: auto; @@ -195,7 +205,6 @@ .data-table-col .edit-cell { display: none; - // position: absolute; padding: 8px; padding: 0.5rem; background: #fff; @@ -203,12 +212,6 @@ height: 100%; } -.data-table-col .edit-cell input { - outline: none; - width: 100%; - border: none; - } - .data-table-col.selected .content { border: 2px solid rgb(82, 146, 247); } diff --git a/dist/frappe-datatable.js b/dist/frappe-datatable.js index 672256d..d059008 100644 --- a/dist/frappe-datatable.js +++ b/dist/frappe-datatable.js @@ -176,6 +176,548 @@ $.scrollTop = function scrollTop(element, pixels) { }); }; +/** + * Checks if `value` is the + * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) + * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is an object, else `false`. + * @example + * + * _.isObject({}); + * // => true + * + * _.isObject([1, 2, 3]); + * // => true + * + * _.isObject(_.noop); + * // => true + * + * _.isObject(null); + * // => false + */ +function isObject(value) { + var type = typeof value; + return value != null && (type == 'object' || type == 'function'); +} + +var isObject_1 = isObject; + +var commonjsGlobal = typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + +/** Detect free variable `global` from Node.js. */ +var freeGlobal = typeof commonjsGlobal == 'object' && commonjsGlobal && commonjsGlobal.Object === Object && commonjsGlobal; + +var _freeGlobal = freeGlobal; + +/** Detect free variable `self`. */ +var freeSelf = typeof self == 'object' && self && self.Object === Object && self; + +/** Used as a reference to the global object. */ +var root = _freeGlobal || freeSelf || Function('return this')(); + +var _root = root; + +/** + * Gets the timestamp of the number of milliseconds that have elapsed since + * the Unix epoch (1 January 1970 00:00:00 UTC). + * + * @static + * @memberOf _ + * @since 2.4.0 + * @category Date + * @returns {number} Returns the timestamp. + * @example + * + * _.defer(function(stamp) { + * console.log(_.now() - stamp); + * }, _.now()); + * // => Logs the number of milliseconds it took for the deferred invocation. + */ +var now = function() { + return _root.Date.now(); +}; + +var now_1 = now; + +/** Built-in value references. */ +var Symbol = _root.Symbol; + +var _Symbol = Symbol; + +/** Used for built-in method references. */ +var objectProto = Object.prototype; + +/** Used to check objects for own properties. */ +var hasOwnProperty = objectProto.hasOwnProperty; + +/** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */ +var nativeObjectToString = objectProto.toString; + +/** Built-in value references. */ +var symToStringTag = _Symbol ? _Symbol.toStringTag : undefined; + +/** + * A specialized version of `baseGetTag` which ignores `Symbol.toStringTag` values. + * + * @private + * @param {*} value The value to query. + * @returns {string} Returns the raw `toStringTag`. + */ +function getRawTag(value) { + var isOwn = hasOwnProperty.call(value, symToStringTag), + tag = value[symToStringTag]; + + try { + value[symToStringTag] = undefined; + var unmasked = true; + } catch (e) {} + + var result = nativeObjectToString.call(value); + if (unmasked) { + if (isOwn) { + value[symToStringTag] = tag; + } else { + delete value[symToStringTag]; + } + } + return result; +} + +var _getRawTag = getRawTag; + +/** Used for built-in method references. */ +var objectProto$1 = Object.prototype; + +/** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */ +var nativeObjectToString$1 = objectProto$1.toString; + +/** + * Converts `value` to a string using `Object.prototype.toString`. + * + * @private + * @param {*} value The value to convert. + * @returns {string} Returns the converted string. + */ +function objectToString(value) { + return nativeObjectToString$1.call(value); +} + +var _objectToString = objectToString; + +/** `Object#toString` result references. */ +var nullTag = '[object Null]'; +var undefinedTag = '[object Undefined]'; + +/** Built-in value references. */ +var symToStringTag$1 = _Symbol ? _Symbol.toStringTag : undefined; + +/** + * The base implementation of `getTag` without fallbacks for buggy environments. + * + * @private + * @param {*} value The value to query. + * @returns {string} Returns the `toStringTag`. + */ +function baseGetTag(value) { + if (value == null) { + return value === undefined ? undefinedTag : nullTag; + } + return (symToStringTag$1 && symToStringTag$1 in Object(value)) + ? _getRawTag(value) + : _objectToString(value); +} + +var _baseGetTag = baseGetTag; + +/** + * Checks if `value` is object-like. A value is object-like if it's not `null` + * and has a `typeof` result of "object". + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is object-like, else `false`. + * @example + * + * _.isObjectLike({}); + * // => true + * + * _.isObjectLike([1, 2, 3]); + * // => true + * + * _.isObjectLike(_.noop); + * // => false + * + * _.isObjectLike(null); + * // => false + */ +function isObjectLike(value) { + return value != null && typeof value == 'object'; +} + +var isObjectLike_1 = isObjectLike; + +/** `Object#toString` result references. */ +var symbolTag = '[object Symbol]'; + +/** + * Checks if `value` is classified as a `Symbol` primitive or object. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a symbol, else `false`. + * @example + * + * _.isSymbol(Symbol.iterator); + * // => true + * + * _.isSymbol('abc'); + * // => false + */ +function isSymbol(value) { + return typeof value == 'symbol' || + (isObjectLike_1(value) && _baseGetTag(value) == symbolTag); +} + +var isSymbol_1 = isSymbol; + +/** Used as references for various `Number` constants. */ +var NAN = 0 / 0; + +/** Used to match leading and trailing whitespace. */ +var reTrim = /^\s+|\s+$/g; + +/** Used to detect bad signed hexadecimal string values. */ +var reIsBadHex = /^[-+]0x[0-9a-f]+$/i; + +/** Used to detect binary string values. */ +var reIsBinary = /^0b[01]+$/i; + +/** Used to detect octal string values. */ +var reIsOctal = /^0o[0-7]+$/i; + +/** Built-in method references without a dependency on `root`. */ +var freeParseInt = parseInt; + +/** + * Converts `value` to a number. + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to process. + * @returns {number} Returns the number. + * @example + * + * _.toNumber(3.2); + * // => 3.2 + * + * _.toNumber(Number.MIN_VALUE); + * // => 5e-324 + * + * _.toNumber(Infinity); + * // => Infinity + * + * _.toNumber('3.2'); + * // => 3.2 + */ +function toNumber(value) { + if (typeof value == 'number') { + return value; + } + if (isSymbol_1(value)) { + return NAN; + } + if (isObject_1(value)) { + var other = typeof value.valueOf == 'function' ? value.valueOf() : value; + value = isObject_1(other) ? (other + '') : other; + } + if (typeof value != 'string') { + return value === 0 ? value : +value; + } + value = value.replace(reTrim, ''); + var isBinary = reIsBinary.test(value); + return (isBinary || reIsOctal.test(value)) + ? freeParseInt(value.slice(2), isBinary ? 2 : 8) + : (reIsBadHex.test(value) ? NAN : +value); +} + +var toNumber_1 = toNumber; + +/** Error message constants. */ +var FUNC_ERROR_TEXT = 'Expected a function'; + +/* Built-in method references for those with the same name as other `lodash` methods. */ +var nativeMax = Math.max; +var nativeMin = Math.min; + +/** + * Creates a debounced function that delays invoking `func` until after `wait` + * milliseconds have elapsed since the last time the debounced function was + * invoked. The debounced function comes with a `cancel` method to cancel + * delayed `func` invocations and a `flush` method to immediately invoke them. + * Provide `options` to indicate whether `func` should be invoked on the + * leading and/or trailing edge of the `wait` timeout. The `func` is invoked + * with the last arguments provided to the debounced function. Subsequent + * calls to the debounced function return the result of the last `func` + * invocation. + * + * **Note:** If `leading` and `trailing` options are `true`, `func` is + * invoked on the trailing edge of the timeout only if the debounced function + * is invoked more than once during the `wait` timeout. + * + * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred + * until to the next tick, similar to `setTimeout` with a timeout of `0`. + * + * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) + * for details over the differences between `_.debounce` and `_.throttle`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to debounce. + * @param {number} [wait=0] The number of milliseconds to delay. + * @param {Object} [options={}] The options object. + * @param {boolean} [options.leading=false] + * Specify invoking on the leading edge of the timeout. + * @param {number} [options.maxWait] + * The maximum time `func` is allowed to be delayed before it's invoked. + * @param {boolean} [options.trailing=true] + * Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns the new debounced function. + * @example + * + * // Avoid costly calculations while the window size is in flux. + * jQuery(window).on('resize', _.debounce(calculateLayout, 150)); + * + * // Invoke `sendMail` when clicked, debouncing subsequent calls. + * jQuery(element).on('click', _.debounce(sendMail, 300, { + * 'leading': true, + * 'trailing': false + * })); + * + * // Ensure `batchLog` is invoked once after 1 second of debounced calls. + * var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 }); + * var source = new EventSource('/stream'); + * jQuery(source).on('message', debounced); + * + * // Cancel the trailing debounced invocation. + * jQuery(window).on('popstate', debounced.cancel); + */ +function debounce(func, wait, options) { + var lastArgs, + lastThis, + maxWait, + result, + timerId, + lastCallTime, + lastInvokeTime = 0, + leading = false, + maxing = false, + trailing = true; + + if (typeof func != 'function') { + throw new TypeError(FUNC_ERROR_TEXT); + } + wait = toNumber_1(wait) || 0; + if (isObject_1(options)) { + leading = !!options.leading; + maxing = 'maxWait' in options; + maxWait = maxing ? nativeMax(toNumber_1(options.maxWait) || 0, wait) : maxWait; + trailing = 'trailing' in options ? !!options.trailing : trailing; + } + + function invokeFunc(time) { + var args = lastArgs, + thisArg = lastThis; + + lastArgs = lastThis = undefined; + lastInvokeTime = time; + result = func.apply(thisArg, args); + return result; + } + + function leadingEdge(time) { + // Reset any `maxWait` timer. + lastInvokeTime = time; + // Start the timer for the trailing edge. + timerId = setTimeout(timerExpired, wait); + // Invoke the leading edge. + return leading ? invokeFunc(time) : result; + } + + function remainingWait(time) { + var timeSinceLastCall = time - lastCallTime, + timeSinceLastInvoke = time - lastInvokeTime, + timeWaiting = wait - timeSinceLastCall; + + return maxing + ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke) + : timeWaiting; + } + + function shouldInvoke(time) { + var timeSinceLastCall = time - lastCallTime, + timeSinceLastInvoke = time - lastInvokeTime; + + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge, or we've hit the `maxWait` limit. + return (lastCallTime === undefined || (timeSinceLastCall >= wait) || + (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait)); + } + + function timerExpired() { + var time = now_1(); + if (shouldInvoke(time)) { + return trailingEdge(time); + } + // Restart the timer. + timerId = setTimeout(timerExpired, remainingWait(time)); + } + + function trailingEdge(time) { + timerId = undefined; + + // Only invoke if we have `lastArgs` which means `func` has been + // debounced at least once. + if (trailing && lastArgs) { + return invokeFunc(time); + } + lastArgs = lastThis = undefined; + return result; + } + + function cancel() { + if (timerId !== undefined) { + clearTimeout(timerId); + } + lastInvokeTime = 0; + lastArgs = lastCallTime = lastThis = timerId = undefined; + } + + function flush() { + return timerId === undefined ? result : trailingEdge(now_1()); + } + + function debounced() { + var time = now_1(), + isInvoking = shouldInvoke(time); + + lastArgs = arguments; + lastThis = this; + lastCallTime = time; + + if (isInvoking) { + if (timerId === undefined) { + return leadingEdge(lastCallTime); + } + if (maxing) { + // Handle invocations in a tight loop. + timerId = setTimeout(timerExpired, wait); + return invokeFunc(lastCallTime); + } + } + if (timerId === undefined) { + timerId = setTimeout(timerExpired, wait); + } + return result; + } + debounced.cancel = cancel; + debounced.flush = flush; + return debounced; +} + +var debounce_1 = debounce; + +/** Error message constants. */ +var FUNC_ERROR_TEXT$1 = 'Expected a function'; + +/** + * Creates a throttled function that only invokes `func` at most once per + * every `wait` milliseconds. The throttled function comes with a `cancel` + * method to cancel delayed `func` invocations and a `flush` method to + * immediately invoke them. Provide `options` to indicate whether `func` + * should be invoked on the leading and/or trailing edge of the `wait` + * timeout. The `func` is invoked with the last arguments provided to the + * throttled function. Subsequent calls to the throttled function return the + * result of the last `func` invocation. + * + * **Note:** If `leading` and `trailing` options are `true`, `func` is + * invoked on the trailing edge of the timeout only if the throttled function + * is invoked more than once during the `wait` timeout. + * + * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred + * until to the next tick, similar to `setTimeout` with a timeout of `0`. + * + * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) + * for details over the differences between `_.throttle` and `_.debounce`. + * + * @static + * @memberOf _ + * @since 0.1.0 + * @category Function + * @param {Function} func The function to throttle. + * @param {number} [wait=0] The number of milliseconds to throttle invocations to. + * @param {Object} [options={}] The options object. + * @param {boolean} [options.leading=true] + * Specify invoking on the leading edge of the timeout. + * @param {boolean} [options.trailing=true] + * Specify invoking on the trailing edge of the timeout. + * @returns {Function} Returns the new throttled function. + * @example + * + * // Avoid excessively updating the position while scrolling. + * jQuery(window).on('scroll', _.throttle(updatePosition, 100)); + * + * // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes. + * var throttled = _.throttle(renewToken, 300000, { 'trailing': false }); + * jQuery(element).on('click', throttled); + * + * // Cancel the trailing throttled invocation. + * jQuery(window).on('popstate', throttled.cancel); + */ +function throttle(func, wait, options) { + var leading = true, + trailing = true; + + if (typeof func != 'function') { + throw new TypeError(FUNC_ERROR_TEXT$1); + } + if (isObject_1(options)) { + leading = 'leading' in options ? !!options.leading : leading; + trailing = 'trailing' in options ? !!options.trailing : trailing; + } + return debounce_1(func, wait, { + 'leading': leading, + 'maxWait': wait, + 'trailing': trailing + }); +} + +var throttle_1 = throttle; + function camelCaseToDash(str) { return str.replace(/([A-Z])/g, (g) => `-${g[0].toLowerCase()}`); } @@ -268,47 +810,16 @@ function isNumeric(val) { return !isNaN(val); } -// https://stackoverflow.com/a/27078401 -function throttle(func, wait, options) { - var context, args, result; - var timeout = null; - var previous = 0; - if (!options) options = {}; +let throttle$1 = throttle_1; - let later = function () { - previous = options.leading === false ? 0 : Date.now(); - timeout = null; - result = func.apply(context, args); - if (!timeout) context = args = null; - }; - - return function () { - var now = Date.now(); - if (!previous && options.leading === false) previous = now; - let remaining = wait - (now - previous); - context = this; - args = arguments; - if (remaining <= 0 || remaining > wait) { - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - previous = now; - result = func.apply(context, args); - if (!timeout) context = args = null; - } else if (!timeout && options.trailing !== false) { - timeout = setTimeout(later, remaining); - } - return result; - }; -} +let debounce$2 = debounce_1; function promisify(fn, context = null) { return (...args) => { return new Promise(resolve => { setTimeout(() => { - fn.apply(context, args); - resolve('done', fn.name); + const out = fn.apply(context, args); + resolve(out); }, 0); }); }; @@ -334,6 +845,7 @@ class DataManager { this.sortRows = promisify(this.sortRows, this); this.switchColumn = promisify(this.switchColumn, this); this.removeColumn = promisify(this.removeColumn, this); + this.filterRows = promisify(this.filterRows, this); } init(data) { @@ -715,6 +1227,25 @@ class DataManager { return column; } + filterRows(keyword, colIndex) { + let rowsToHide = []; + let rowsToShow = []; + const cells = this.rows.map(row => row[colIndex]); + + cells.forEach(cell => { + const hay = cell.content.toLowerCase(); + const needle = (keyword || '').toLowerCase(); + + if (!needle || hay.includes(needle)) { + rowsToShow.push(cell.rowIndex); + } else { + rowsToHide.push(cell.rowIndex); + } + }); + + return {rowsToHide, rowsToShow}; + } + getRowCount() { return this.rowCount; } @@ -838,7 +1369,19 @@ class ColumnManager { if (!$('.data-table-col', this.header)) { // insert html - $('thead', this.header).innerHTML = this.rowmanager.getRowHTML(columns, { isHeader: 1 }); + + let html = this.rowmanager.getRowHTML(columns, { isHeader: 1 }); + if (this.options.enableInlineFilters) { + html += this.rowmanager.getRowHTML(columns, { isFilter: 1 }); + } + + $('thead', this.header).innerHTML = html; + + this.$filterRow = $('.data-table-row[data-is-filter]', this.header); + // hide filter row immediately, so it doesn't disturb layout + $.style(this.$filterRow, { + display: 'none' + }); } else { // refresh dom state const $cols = $.each('.data-table-col', this.header); @@ -871,6 +1414,7 @@ class ColumnManager { this.bindDropdown(); this.bindResizeColumn(); this.bindMoveColumn(); + this.bindFilter(); } bindDropdown() { @@ -1072,6 +1616,51 @@ class ColumnManager { }); } + toggleFilter() { + this.isFilterShown = this.isFilterShown || false; + + if (this.isFilterShown) { + $.style(this.$filterRow, { + display: 'none' + }); + } else { + $.style(this.$filterRow, { + display: '' + }); + } + + this.isFilterShown = !this.isFilterShown; + this.style.setBodyStyle(); + } + + focusFilter(colIndex) { + if (!this.isFilterShown) return; + + const $filterInput = $(`[data-col-index="${colIndex}"] .data-table-filter`, this.$filterRow); + $filterInput.focus(); + } + + bindFilter() { + const handler = e => { + const $filterCell = $.closest('.data-table-col', e.target); + const { colIndex } = $.data($filterCell); + const keyword = e.target.value; + + this.datamanager.filterRows(keyword, colIndex) + .then(({ rowsToHide, rowsToShow }) => { + rowsToHide.map(rowIndex => { + const $tr = $(`.data-table-row[data-row-index="${rowIndex}"]`, this.bodyScrollable); + $tr.classList.add('hide'); + }); + rowsToShow.map(rowIndex => { + const $tr = $(`.data-table-row[data-row-index="${rowIndex}"]`, this.bodyScrollable); + $tr.classList.remove('hide'); + }); + }); + }; + $.on(this.header, 'keydown', '.data-table-filter', debounce$2(handler, 300)); + } + sortRows(colIndex, sortOrder) { return this.datamanager.sortRows(colIndex, sortOrder); } @@ -1103,7 +1692,7 @@ class ColumnManager { setColumnHeaderWidth(colIndex) { colIndex = +colIndex; this.$columnMap = this.$columnMap || []; - const selector = `[data-col-index="${colIndex}"][data-is-header] .content`; + const selector = `.data-table-header [data-col-index="${colIndex}"] .content`; const { width } = this.getColumn(colIndex); let $column = this.$columnMap[colIndex]; @@ -1260,6 +1849,14 @@ class CellManager { this.keyboard.on('esc', () => { this.deactivateEditing(); }); + + this.keyboard.on('ctrl+f', (e) => { + const $cell = $.closest('.data-table-col', e.target); + let { colIndex } = $.data($cell); + + this.activateFilter(colIndex); + return true; + }); } bindKeyboardSelection() { @@ -1308,7 +1905,7 @@ class CellManager { this.selectArea($(e.delegatedTarget)); }; - $.on(this.bodyScrollable, 'mousemove', '.data-table-col', throttle(selectArea, 50)); + $.on(this.bodyScrollable, 'mousemove', '.data-table-col', throttle$1(selectArea, 50)); } focusCell($cell, { skipClearSelection = 0 } = {}) { @@ -1614,6 +2211,16 @@ class CellManager { copyTextToClipboard(values); } + activateFilter(colIndex) { + this.columnmanager.toggleFilter(); + this.columnmanager.focusFilter(colIndex); + + if (!this.columnmanager.isFilterShown) { + // put focus back on cell + this.$focusedCell.focus(); + } + } + updateCell(colIndex, rowIndex, value) { const cell = this.datamanager.updateCell(colIndex, rowIndex, { content: value @@ -1698,11 +2305,12 @@ class CellManager { } getCellHTML(cell) { - const { rowIndex, colIndex, isHeader } = cell; + const { rowIndex, colIndex, isHeader, isFilter } = cell; const dataAttr = makeDataAttributeString({ rowIndex, colIndex, - isHeader + isHeader, + isFilter }); return ` @@ -1727,7 +2335,12 @@ class CellManager { const hasDropdown = isHeader && cell.dropdown !== false; const dropdown = hasDropdown ? `
${getDropdownHTML()}
` : ''; - const contentHTML = (!cell.isHeader && cell.column.format) ? cell.column.format(cell.content) : cell.content; + let contentHTML; + if (cell.isHeader || cell.isFilter || !cell.column.format) { + contentHTML = cell.content; + } else { + contentHTML = cell.column.format(cell.content); + } return `
@@ -1742,7 +2355,7 @@ class CellManager { getEditCellHTML() { return ` -
+
`; } @@ -1937,12 +2550,27 @@ class RowManager { getRowHTML(row, props) { const dataAttr = makeDataAttributeString(props); + if (props.isFilter) { + row = row.map(cell => (Object.assign(cell, { + content: this.getFilterInput({ colIndex: cell.colIndex }), + format: value => value, + isFilter: 1, + isHeader: undefined, + editable: false + }))); + } + return ` ${row.map(cell => this.cellmanager.getCellHTML(cell)).join('')} `; } + + getFilterInput(props) { + const dataAttr = makeDataAttributeString(props); + return ``; + } } class BodyRenderer { @@ -2063,7 +2691,7 @@ class Style { bindResizeWindow() { if (this.options.layout === 'fluid') { - $.on(window, 'resize', throttle(() => { + $.on(window, 'resize', throttle$1(() => { this.distributeRemainingWidth(); this.refreshColumnWidth(); this.setBodyStyle(); @@ -2135,7 +2763,7 @@ class Style { } setupMinWidth() { - $.each('.data-table-col', this.header).map(col => { + $.each('.data-table-col[data-is-header]', this.header).map(col => { const width = $.style($('.content', col), 'width'); const { colIndex @@ -2272,7 +2900,8 @@ const KEYCODES = { 40: 'down', 9: 'tab', 27: 'esc', - 67: 'c' + 67: 'c', + 70: 'f' }; class Keyboard { @@ -2296,7 +2925,7 @@ class Keyboard { if (listeners && listeners.length > 0) { for (let listener of listeners) { - const preventBubbling = listener(); + const preventBubbling = listener(e); if (preventBubbling === undefined || preventBubbling === true) { e.preventDefault(); } @@ -2362,7 +2991,8 @@ var DEFAULT_OPTIONS = { enableLogs: false, layout: 'fixed', // fixed, fluid noDataMessage: 'No Data', - cellHeight: null + cellHeight: null, + enableInlineFilters: false }; class DataTable { @@ -2536,14 +3166,14 @@ var version = "0.0.2"; var description = "A modern datatable library for the web"; var main = "dist/frappe-datatable.cjs.js"; var scripts = {"start":"npm run dev","build":"rollup -c","dev":"rollup -c -w","test":"mocha --compilers js:babel-core/register --colors ./test/*.spec.js","test:watch":"mocha --compilers js:babel-core/register --colors -w ./test/*.spec.js"}; -var devDependencies = {"chai":"3.5.0","cssnano":"^3.10.0","deepmerge":"^2.0.1","eslint":"3.19.0","eslint-loader":"1.7.1","mocha":"3.3.0","postcss-cssnext":"^3.1.0","postcss-nested":"^3.0.0","precss":"^3.1.0","rollup-plugin-json":"^2.3.0","rollup-plugin-postcss":"^1.2.8","rollup-plugin-uglify":"^3.0.0"}; +var devDependencies = {"chai":"3.5.0","cssnano":"^3.10.0","deepmerge":"^2.0.1","eslint":"3.19.0","eslint-loader":"1.7.1","mocha":"3.3.0","postcss-cssnext":"^3.1.0","postcss-nested":"^3.0.0","precss":"^3.1.0","rollup-plugin-commonjs":"^8.3.0","rollup-plugin-json":"^2.3.0","rollup-plugin-node-resolve":"^3.0.3","rollup-plugin-postcss":"^1.2.8","rollup-plugin-uglify":"^3.0.0"}; var repository = {"type":"git","url":"https://github.com/frappe/datatable.git"}; var keywords = ["datatable","data","grid","table"]; var author = "Faris Ansari"; var license = "MIT"; var bugs = {"url":"https://github.com/frappe/datatable/issues"}; var homepage = "https://frappe.github.io/datatable"; -var dependencies = {"clusterize.js":"^0.18.0","sortablejs":"^1.7.0"}; +var dependencies = {"clusterize.js":"^0.18.0","lodash":"^4.17.5","sortablejs":"^1.7.0"}; var packageJson = { name: name, version: version, diff --git a/index.html b/index.html index 1a2690f..a4c587b 100644 --- a/index.html +++ b/index.html @@ -70,6 +70,7 @@ layout: 'fluid', columns, data, + enableInlineFilters: true, getEditor(colIndex, rowIndex, value, parent) { // editing obj only for date field if (colIndex != 6) return; diff --git a/package.json b/package.json index f93bd5e..757f77a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "postcss-cssnext": "^3.1.0", "postcss-nested": "^3.0.0", "precss": "^3.1.0", + "rollup-plugin-commonjs": "^8.3.0", "rollup-plugin-json": "^2.3.0", + "rollup-plugin-node-resolve": "^3.0.3", "rollup-plugin-postcss": "^1.2.8", "rollup-plugin-uglify": "^3.0.0" }, @@ -42,6 +44,7 @@ "homepage": "https://frappe.github.io/datatable", "dependencies": { "clusterize.js": "^0.18.0", + "lodash": "^4.17.5", "sortablejs": "^1.7.0" } } diff --git a/rollup.config.js b/rollup.config.js index e006303..5aacb6b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,5 +1,7 @@ import json from 'rollup-plugin-json'; // import uglify from 'rollup-plugin-uglify'; +import nodeResolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; import postcss from 'rollup-plugin-postcss'; import nested from 'postcss-nested'; import cssnext from 'postcss-cssnext'; @@ -18,6 +20,8 @@ const dev = { }, plugins: [ json(), + nodeResolve(), + commonjs(), postcss({ extract: 'dist/frappe-datatable.css', plugins: [ diff --git a/src/cellmanager.js b/src/cellmanager.js index b03cb31..eeff133 100644 --- a/src/cellmanager.js +++ b/src/cellmanager.js @@ -107,6 +107,14 @@ export default class CellManager { this.keyboard.on('esc', () => { this.deactivateEditing(); }); + + this.keyboard.on('ctrl+f', (e) => { + const $cell = $.closest('.data-table-col', e.target); + let { colIndex } = $.data($cell); + + this.activateFilter(colIndex); + return true; + }); } bindKeyboardSelection() { @@ -461,6 +469,16 @@ export default class CellManager { copyTextToClipboard(values); } + activateFilter(colIndex) { + this.columnmanager.toggleFilter(); + this.columnmanager.focusFilter(colIndex); + + if (!this.columnmanager.isFilterShown) { + // put focus back on cell + this.$focusedCell.focus(); + } + } + updateCell(colIndex, rowIndex, value) { const cell = this.datamanager.updateCell(colIndex, rowIndex, { content: value @@ -545,11 +563,12 @@ export default class CellManager { } getCellHTML(cell) { - const { rowIndex, colIndex, isHeader } = cell; + const { rowIndex, colIndex, isHeader, isFilter } = cell; const dataAttr = makeDataAttributeString({ rowIndex, colIndex, - isHeader + isHeader, + isFilter }); return ` @@ -574,7 +593,12 @@ export default class CellManager { const hasDropdown = isHeader && cell.dropdown !== false; const dropdown = hasDropdown ? `
${getDropdownHTML()}
` : ''; - const contentHTML = (!cell.isHeader && cell.column.format) ? cell.column.format(cell.content) : cell.content; + let contentHTML; + if (cell.isHeader || cell.isFilter || !cell.column.format) { + contentHTML = cell.content; + } else { + contentHTML = cell.column.format(cell.content); + } return `
@@ -589,7 +613,7 @@ export default class CellManager { getEditCellHTML() { return ` -
+
`; } diff --git a/src/columnmanager.js b/src/columnmanager.js index 4fc0a1a..c5286b6 100644 --- a/src/columnmanager.js +++ b/src/columnmanager.js @@ -1,6 +1,6 @@ import $ from './dom'; import Sortable from 'sortablejs'; -import { getDefault, linkProperties } from './utils'; +import { getDefault, linkProperties, debounce } from './utils'; export default class ColumnManager { constructor(instance) { @@ -31,7 +31,19 @@ export default class ColumnManager { if (!$('.data-table-col', this.header)) { // insert html - $('thead', this.header).innerHTML = this.rowmanager.getRowHTML(columns, { isHeader: 1 }); + + let html = this.rowmanager.getRowHTML(columns, { isHeader: 1 }); + if (this.options.enableInlineFilters) { + html += this.rowmanager.getRowHTML(columns, { isFilter: 1 }); + } + + $('thead', this.header).innerHTML = html; + + this.$filterRow = $('.data-table-row[data-is-filter]', this.header); + // hide filter row immediately, so it doesn't disturb layout + $.style(this.$filterRow, { + display: 'none' + }); } else { // refresh dom state const $cols = $.each('.data-table-col', this.header); @@ -64,6 +76,7 @@ export default class ColumnManager { this.bindDropdown(); this.bindResizeColumn(); this.bindMoveColumn(); + this.bindFilter(); } bindDropdown() { @@ -265,6 +278,51 @@ export default class ColumnManager { }); } + toggleFilter() { + this.isFilterShown = this.isFilterShown || false; + + if (this.isFilterShown) { + $.style(this.$filterRow, { + display: 'none' + }); + } else { + $.style(this.$filterRow, { + display: '' + }); + } + + this.isFilterShown = !this.isFilterShown; + this.style.setBodyStyle(); + } + + focusFilter(colIndex) { + if (!this.isFilterShown) return; + + const $filterInput = $(`[data-col-index="${colIndex}"] .data-table-filter`, this.$filterRow); + $filterInput.focus(); + } + + bindFilter() { + const handler = e => { + const $filterCell = $.closest('.data-table-col', e.target); + const { colIndex } = $.data($filterCell); + const keyword = e.target.value; + + this.datamanager.filterRows(keyword, colIndex) + .then(({ rowsToHide, rowsToShow }) => { + rowsToHide.map(rowIndex => { + const $tr = $(`.data-table-row[data-row-index="${rowIndex}"]`, this.bodyScrollable); + $tr.classList.add('hide'); + }); + rowsToShow.map(rowIndex => { + const $tr = $(`.data-table-row[data-row-index="${rowIndex}"]`, this.bodyScrollable); + $tr.classList.remove('hide'); + }); + }); + }; + $.on(this.header, 'keydown', '.data-table-filter', debounce(handler, 300)); + } + sortRows(colIndex, sortOrder) { return this.datamanager.sortRows(colIndex, sortOrder); } @@ -296,7 +354,7 @@ export default class ColumnManager { setColumnHeaderWidth(colIndex) { colIndex = +colIndex; this.$columnMap = this.$columnMap || []; - const selector = `[data-col-index="${colIndex}"][data-is-header] .content`; + const selector = `.data-table-header [data-col-index="${colIndex}"] .content`; const { width } = this.getColumn(colIndex); let $column = this.$columnMap[colIndex]; diff --git a/src/datamanager.js b/src/datamanager.js index 486bd77..8359c1f 100644 --- a/src/datamanager.js +++ b/src/datamanager.js @@ -6,6 +6,7 @@ export default class DataManager { this.sortRows = promisify(this.sortRows, this); this.switchColumn = promisify(this.switchColumn, this); this.removeColumn = promisify(this.removeColumn, this); + this.filterRows = promisify(this.filterRows, this); } init(data) { @@ -387,6 +388,25 @@ export default class DataManager { return column; } + filterRows(keyword, colIndex) { + let rowsToHide = []; + let rowsToShow = []; + const cells = this.rows.map(row => row[colIndex]); + + cells.forEach(cell => { + const hay = cell.content.toLowerCase(); + const needle = (keyword || '').toLowerCase(); + + if (!needle || hay.includes(needle)) { + rowsToShow.push(cell.rowIndex); + } else { + rowsToHide.push(cell.rowIndex); + } + }); + + return {rowsToHide, rowsToShow}; + } + getRowCount() { return this.rowCount; } diff --git a/src/defaults.js b/src/defaults.js index e164c1e..f306723 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -46,5 +46,6 @@ export default { enableLogs: false, layout: 'fixed', // fixed, fluid noDataMessage: 'No Data', - cellHeight: null + cellHeight: null, + enableInlineFilters: false }; diff --git a/src/keyboard.js b/src/keyboard.js index 099b8dc..abd9e96 100644 --- a/src/keyboard.js +++ b/src/keyboard.js @@ -12,7 +12,8 @@ const KEYCODES = { 40: 'down', 9: 'tab', 27: 'esc', - 67: 'c' + 67: 'c', + 70: 'f' }; export default class Keyboard { @@ -36,7 +37,7 @@ export default class Keyboard { if (listeners && listeners.length > 0) { for (let listener of listeners) { - const preventBubbling = listener(); + const preventBubbling = listener(e); if (preventBubbling === undefined || preventBubbling === true) { e.preventDefault(); } diff --git a/src/rowmanager.js b/src/rowmanager.js index 1e6fc86..0ab1a51 100644 --- a/src/rowmanager.js +++ b/src/rowmanager.js @@ -187,10 +187,25 @@ export default class RowManager { getRowHTML(row, props) { const dataAttr = makeDataAttributeString(props); + if (props.isFilter) { + row = row.map(cell => (Object.assign(cell, { + content: this.getFilterInput({ colIndex: cell.colIndex }), + format: value => value, + isFilter: 1, + isHeader: undefined, + editable: false + }))); + } + return ` ${row.map(cell => this.cellmanager.getCellHTML(cell)).join('')} `; } + + getFilterInput(props) { + const dataAttr = makeDataAttributeString(props); + return ``; + } } diff --git a/src/style.css b/src/style.css index 121e544..9ed0c29 100644 --- a/src/style.css +++ b/src/style.css @@ -27,6 +27,12 @@ padding: 0; } + .input-style { + outline: none; + width: 100%; + border: none; + } + *, *:focus { outline: none; border-radius: 0px; @@ -80,6 +86,10 @@ background: palevioletred; opacity: 0.5; } + + .hide { + display: none; + } } .body-scrollable { @@ -188,17 +198,10 @@ .edit-cell { display: none; - // position: absolute; padding: var(--spacer-2); background: #fff; z-index: 1; height: 100%; - - input { - outline: none; - width: 100%; - border: none; - } } &.selected .content { diff --git a/src/style.js b/src/style.js index f0afea9..fdea46a 100644 --- a/src/style.js +++ b/src/style.js @@ -99,7 +99,7 @@ export default class Style { } setupMinWidth() { - $.each('.data-table-col', this.header).map(col => { + $.each('.data-table-col[data-is-header]', this.header).map(col => { const width = $.style($('.content', col), 'width'); const { colIndex diff --git a/src/utils.js b/src/utils.js index 83f91b8..e2d2be5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,6 @@ +import _throttle from 'lodash/throttle'; +import _debounce from 'lodash/debounce'; + export function camelCaseToDash(str) { return str.replace(/([A-Z])/g, (g) => `-${g[0].toLowerCase()}`); } @@ -148,47 +151,16 @@ export function isNumeric(val) { return !isNaN(val); } -// https://stackoverflow.com/a/27078401 -export function throttle(func, wait, options) { - var context, args, result; - var timeout = null; - var previous = 0; - if (!options) options = {}; +export let throttle = _throttle; - let later = function () { - previous = options.leading === false ? 0 : Date.now(); - timeout = null; - result = func.apply(context, args); - if (!timeout) context = args = null; - }; - - return function () { - var now = Date.now(); - if (!previous && options.leading === false) previous = now; - let remaining = wait - (now - previous); - context = this; - args = arguments; - if (remaining <= 0 || remaining > wait) { - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - previous = now; - result = func.apply(context, args); - if (!timeout) context = args = null; - } else if (!timeout && options.trailing !== false) { - timeout = setTimeout(later, remaining); - } - return result; - }; -}; +export let debounce = _debounce; export function promisify(fn, context = null) { return (...args) => { return new Promise(resolve => { setTimeout(() => { - fn.apply(context, args); - resolve('done', fn.name); + const out = fn.apply(context, args); + resolve(out); }, 0); }); }; diff --git a/yarn.lock b/yarn.lock index 4e90ed7..6f874e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -192,6 +192,10 @@ browserslist@^3.1: caniuse-lite "^1.0.30000808" electron-to-chromium "^1.3.33" +builtin-modules@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" @@ -741,6 +745,10 @@ estree-walker@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.3.1.tgz#e6b1a51cf7292524e7237c312e5fe6660c1ce1aa" +estree-walker@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.5.1.tgz#64fc375053abc6f57d73e9bd2f004644ad3c5854" + esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" @@ -1073,6 +1081,10 @@ is-glob@^2.0.0, is-glob@^2.0.1: dependencies: is-extglob "^1.0.0" +is-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" + is-my-json-valid@^2.10.0: version "2.17.1" resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.17.1.tgz#3da98914a70a22f0a8563ef1511a246c6fc55471" @@ -1327,10 +1339,20 @@ lodash@^4.0.0, lodash@^4.3.0: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" +lodash@^4.17.5: + version "4.17.5" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511" + macaddress@^0.2.8: version "0.2.8" resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" +magic-string@^0.22.4: + version "0.22.4" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.4.tgz#31039b4e40366395618c1d6cf8193c53917475ff" + dependencies: + vlq "^0.2.1" + math-expression-evaluator@^1.2.14: version "1.2.17" resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac" @@ -2341,7 +2363,7 @@ resolve-from@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" -resolve@^1.1.6: +resolve@^1.1.6, resolve@^1.4.0: version "1.5.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.5.0.tgz#1f09acce796c9a762579f31b2c1cc4c3cddf9f36" dependencies: @@ -2368,12 +2390,30 @@ rimraf@^2.2.8, rimraf@^2.6.1: dependencies: glob "^7.0.5" +rollup-plugin-commonjs@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-8.3.0.tgz#91b4ba18f340951e39ed7b1901f377a80ab3f9c3" + dependencies: + acorn "^5.2.1" + estree-walker "^0.5.0" + magic-string "^0.22.4" + resolve "^1.4.0" + rollup-pluginutils "^2.0.1" + rollup-plugin-json@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/rollup-plugin-json/-/rollup-plugin-json-2.3.0.tgz#3c07a452c1b5391be28006fbfff3644056ce0add" dependencies: rollup-pluginutils "^2.0.1" +rollup-plugin-node-resolve@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.0.3.tgz#8f57b253edd00e5b0ad0aed7b7e9cf5982e98fa4" + dependencies: + builtin-modules "^1.1.0" + is-module "^1.0.0" + resolve "^1.1.6" + rollup-plugin-postcss@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rollup-plugin-postcss/-/rollup-plugin-postcss-1.2.8.tgz#3389f4235521cd6a019ab6316cadccb0046c11f3" @@ -2652,6 +2692,10 @@ viewport-dimensions@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/viewport-dimensions/-/viewport-dimensions-0.2.0.tgz#de740747db5387fd1725f5175e91bac76afdf36c" +vlq@^0.2.1: + version "0.2.3" + resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" + whet.extend@~0.9.9: version "0.9.9" resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"