├── .npm └── package │ ├── .gitignore │ ├── README │ └── npm-shrinkwrap.json ├── .gitignore ├── images ├── sort_asc.png ├── sort_both.png ├── sort_desc.png ├── sort_asc_disabled.png └── sort_desc_disabled.png ├── client ├── tabular.html ├── getPubSelector.js ├── tableInit.js └── main.js ├── package.json ├── tests ├── utilIntegration.js ├── reusedFunctions.js ├── mongoDBQuery.js └── util.js ├── .eslintrc.json ├── LICENSE ├── .github └── workflows │ └── comment-issue.yml ├── .versions ├── package.js ├── common ├── Tabular.js └── util.js ├── server └── main.js └── README.md /.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build* 3 | .idea 4 | node_modules -------------------------------------------------------------------------------- /images/sort_asc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meteor-Community-Packages/meteor-tabular/HEAD/images/sort_asc.png -------------------------------------------------------------------------------- /images/sort_both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meteor-Community-Packages/meteor-tabular/HEAD/images/sort_both.png -------------------------------------------------------------------------------- /images/sort_desc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meteor-Community-Packages/meteor-tabular/HEAD/images/sort_desc.png -------------------------------------------------------------------------------- /images/sort_asc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meteor-Community-Packages/meteor-tabular/HEAD/images/sort_asc_disabled.png -------------------------------------------------------------------------------- /images/sort_desc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meteor-Community-Packages/meteor-tabular/HEAD/images/sort_desc_disabled.png -------------------------------------------------------------------------------- /client/tabular.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/eslint-parser": "^7.22.15", 4 | "eslint": "^8.50.0", 5 | "eslint-config-prettier": "^9.0.0", 6 | "eslint-plugin-prettier": "^5.0.0", 7 | "prettier": "^3.0.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /tests/utilIntegration.js: -------------------------------------------------------------------------------- 1 | // 2 | // Most Important Integration Testing ( 3 | // parseMultiFieldColumns, createMongoDBQuery, and createRegExp): 4 | // 5 | Tinytest.add('Util Integration - getPubSelector', function (test) { 6 | var SpacedClassList = ["one"] 7 | var searchString = 'TestSearch' 8 | var BothCols = GenerateBothColumns(SpacedClassList) 9 | var Output = Util.getPubSelector({}, searchString, {}, true, 10 | true, BothCols.ExpectedOutput) 11 | var ExpectedOutput = {"$and":[{},{"$or":[{"one":{"$regex":"TestSearch","$options":"i"}}]}]} 12 | LogResults(BothCols.ExpectedOutput, ExpectedOutput, Output, test) 13 | }) -------------------------------------------------------------------------------- /.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "dependencies": { 4 | "datatables.net": { 5 | "version": "2.0.8", 6 | "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.0.8.tgz", 7 | "integrity": "sha512-4/2dYx4vl975zQqZbyoVEm0huPe61qffjBRby7K7V+y9E+ORq4R8KavkgrNMmIgO6cl85Pg4AvCbVjvPCIT1Yg==" 8 | }, 9 | "jquery": { 10 | "version": "3.7.1", 11 | "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", 12 | "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "jquery": true 7 | }, 8 | "parser": "@babel/eslint-parser", 9 | "parserOptions": { 10 | "requireConfigFile": false, 11 | "ecmaVersion": 2020, 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "sourceType": "module", 16 | "allowImportExportEverywhere": true 17 | }, 18 | "extends": [ 19 | "eslint:recommended", 20 | "plugin:prettier/recommended" 21 | ], 22 | "plugins": [ 23 | "prettier" 24 | ], 25 | "rules": { 26 | "prettier/prettier": [ 27 | "error", 28 | { 29 | "tabWidth": 2, 30 | "printWidth": 100, 31 | "trailingComma": "none", 32 | "singleQuote": true 33 | } 34 | ], 35 | "space-before-function-paren:": 0 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2016 Eric Dobbertin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/comment-issue.yml: -------------------------------------------------------------------------------- 1 | name: Add immediate comment on new issues 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | createComment: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Create Comment 12 | uses: peter-evans/create-or-update-comment@v1.4.2 13 | with: 14 | issue-number: ${{ github.event.issue.number }} 15 | body: | 16 | Thank you for submitting this issue! 17 | 18 | We, the Members of Meteor Community Packages take every issue seriously. 19 | Our goal is to provide long-term lifecycles for packages and keep up 20 | with the newest changes in Meteor and the overall NodeJs/JavaScript ecosystem. 21 | 22 | However, we contribute to these packages mostly in our free time. 23 | Therefore, we can't guarantee your issues to be solved within certain time. 24 | 25 | If you think this issue is trivial to solve, don't hesitate to submit 26 | a pull request, too! We will accompany you in the process with reviews and hints 27 | on how to get development set up. 28 | 29 | Please also consider sponsoring the maintainers of the package. 30 | If you don't know who is currently maintaining this package, just leave a comment 31 | and we'll let you know 32 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | aldeed:tabular@3.0.0-rc.0 2 | allow-deny@1.1.1 3 | anti:fake@0.4.1 4 | babel-compiler@7.10.5 5 | babel-runtime@1.5.1 6 | base64@1.0.12 7 | binary-heap@1.0.11 8 | blaze@2.9.0 9 | blaze-tools@1.0.10 10 | boilerplate-generator@1.7.2 11 | caching-compiler@1.2.2 12 | caching-html-compiler@1.0.5 13 | callback-hook@1.5.1 14 | check@1.4.1 15 | ddp@1.4.1 16 | ddp-client@2.6.2 17 | ddp-common@1.4.1 18 | ddp-server@2.7.1 19 | diff-sequence@1.1.2 20 | dynamic-import@0.7.3 21 | ecmascript@0.16.8 22 | ecmascript-runtime@0.8.1 23 | ecmascript-runtime-client@0.12.1 24 | ecmascript-runtime-server@0.11.0 25 | ejson@1.1.3 26 | fetch@0.1.4 27 | geojson-utils@1.0.11 28 | html-tools@1.0.11 29 | htmljs@1.2.1 30 | id-map@1.1.1 31 | inter-process-messaging@0.1.1 32 | local-test:aldeed:tabular@3.0.0-rc.0 33 | logging@1.3.4 34 | meteor@1.11.5 35 | minimongo@1.9.4 36 | modern-browsers@0.1.10 37 | modules@0.20.0 38 | modules-runtime@0.13.1 39 | mongo@1.16.10 40 | mongo-decimal@0.1.3 41 | mongo-dev-server@1.1.0 42 | mongo-id@1.0.8 43 | npm-mongo@4.17.2 44 | observe-sequence@1.0.21 45 | ordered-dict@1.1.0 46 | promise@0.12.2 47 | random@1.2.1 48 | react-fast-refresh@0.2.8 49 | reactive-dict@1.3.1 50 | reactive-var@1.0.12 51 | reload@1.3.1 52 | retry@1.1.0 53 | routepolicy@1.1.1 54 | session@1.2.1 55 | socket-stream-client@0.5.2 56 | spacebars@1.0.10 57 | spacebars-compiler@1.1.0 58 | templating@1.1.8 59 | templating-tools@1.1.1 60 | tinytest@1.2.3 61 | tracker@1.3.3 62 | typescript@4.9.5 63 | underscore@1.6.1 64 | webapp@1.13.8 65 | webapp-hashing@1.1.1 66 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | /* global Package, Npm */ 2 | 3 | Package.describe({ 4 | name: 'aldeed:tabular', 5 | summary: 'Datatables for large or small datasets in Meteor', 6 | version: '3.0.0-rc.0', 7 | git: 'https://github.com/Meteor-Community-Packages/meteor-tabular.git' 8 | }); 9 | 10 | Npm.depends({ 11 | 'datatables.net': '2.0.8' 12 | }); 13 | 14 | Package.onUse(function(api) { 15 | api.versionsFrom([ '1.3', '2.8.0', '3.0-rc.4']); 16 | api.use([ 17 | 'check', 18 | 'ecmascript', 19 | 'underscore', 20 | 'mongo', 21 | 'blaze@2.9.0 || 3.0.0-rc300.2', 22 | 'templating', 23 | 'reactive-var', 24 | 'tracker', 25 | 'session' 26 | ]); 27 | 28 | // jquery is a weak reference in case you want to use a different package or 29 | // pull it in another way, but regardless you need to make sure it is loaded 30 | // before any tabular tables are rendered 31 | api.use(['jquery@1.1.6 || 3.0.0 || 3.0.1-alpha300.10'], 'client', {weak: true}); 32 | 33 | api.use(['meteorhacks:subs-manager@1.2.0'], ['client', 'server'], {weak: true}); 34 | 35 | api.mainModule('server/main.js', 'server'); 36 | api.mainModule('client/main.js', 'client'); 37 | 38 | api.export('Tabular'); 39 | 40 | // images 41 | api.addAssets([ 42 | 'images/sort_asc.png', 43 | 'images/sort_asc_disabled.png', 44 | 'images/sort_both.png', 45 | 'images/sort_desc.png', 46 | 'images/sort_desc_disabled.png' 47 | ], 'client'); 48 | }); 49 | 50 | Package.onTest(function(api) { 51 | api.versionsFrom([ '1.3', '2.8.0', '3.0-rc.4']); 52 | api.use(['aldeed:tabular', 'tinytest']); 53 | api.use([ 54 | 'anti:fake', 55 | 'check', 56 | 'underscore', 57 | 'reactive-var', 58 | 'tracker', 59 | 'ecmascript' 60 | ]); 61 | 62 | // Load this first: 63 | api.addFiles('tests/reusedFunctions.js', 'client'); 64 | api.addFiles([ 65 | 'tests/util.js', 66 | 'tests/mongoDBQuery.js', 67 | 'tests/utilIntegration.js' 68 | ], 'client' ); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/reusedFunctions.js: -------------------------------------------------------------------------------- 1 | // 2 | // Most basic structure for a unit test with error handling 3 | // and basic details logged to web application 4 | // 5 | LogResults = function(Input, ExpectedOutput, Output, test) { 6 | // Actual Test: 7 | test.equal(Output, ExpectedOutput) 8 | 9 | // Make sure to open a dev tools console to view output 10 | // Should only appear for errors and solves 90% of typo issues: 11 | if (test.current_fail_count > 0) { 12 | console.log('#'+test.test_case.name+' (Failed)'); 13 | console.log('> Input:') 14 | console.log(Input); 15 | console.log('> ExpectedOutput:') 16 | console.log(ExpectedOutput) 17 | console.log('> Actual Output:') 18 | console.log(Output) 19 | console.log(''); 20 | } 21 | } 22 | 23 | 24 | GenerateBothColumns = function(SpacedClassList) { 25 | var BothCols = {} // Its easier to return an object 26 | BothCols.columns = [] // Note: should be an array 27 | BothCols.ExpectedOutput = [] // likewise, output is array 28 | _.each(SpacedClassList, function(ClassList) { 29 | BothCols.columns.push({ 30 | class: ClassList, 31 | query: ClassList, 32 | orderable: true, 33 | options: { 34 | sortfield: 'url' 35 | } 36 | }) 37 | var Classes = ClassList.split(' ') 38 | BothCols.ExpectedOutput = BothCols.ExpectedOutput.concat( 39 | _.map(Classes, function(Class) { 40 | return { 41 | class: ClassList, 42 | query: Class, 43 | orderable: true, 44 | options: { 45 | sortfield: 'url' 46 | } 47 | } 48 | }) 49 | ) 50 | }) 51 | return BothCols; 52 | } 53 | 54 | 55 | createRegExpField = function(SpacedClassList, searchString, PassedOptions) { 56 | var columns = [] // Note: this is usually an array 57 | _.each(SpacedClassList, function(ClassList) { 58 | var Classes = ClassList.split(' ') 59 | columns = columns.concat( 60 | _.map(Classes, function(Class) { 61 | return { 62 | data: Class, 63 | search: { 64 | value: searchString 65 | }, 66 | class: ClassList, 67 | options: PassedOptions 68 | } 69 | }) 70 | ) 71 | }) 72 | return columns; 73 | } 74 | -------------------------------------------------------------------------------- /common/Tabular.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Mongo } from 'meteor/mongo'; 3 | import { _ } from 'meteor/underscore'; 4 | 5 | const Tabular = {}; 6 | 7 | Tabular.tablesByName = {}; 8 | 9 | Tabular.Table = class { 10 | constructor(options) { 11 | if (!options) { 12 | throw new Error('Tabular.Table options argument is required'); 13 | } 14 | if (!options.name) { 15 | throw new Error('Tabular.Table options must specify name'); 16 | } 17 | if (!options.columns) { 18 | throw new Error('Tabular.Table options must specify columns'); 19 | } 20 | if ( 21 | !( 22 | ( 23 | options.collection instanceof Mongo.Collection || 24 | options.collection instanceof Mongo.constructor 25 | ) // Fix: error if `collection: Meteor.users` 26 | ) 27 | ) { 28 | throw new Error('Tabular.Table options must specify collection'); 29 | } 30 | 31 | this.name = options.name; 32 | this.collection = options.collection; 33 | 34 | this.pub = options.pub || 'tabular_genericPub'; 35 | 36 | // By default we use core `Meteor.subscribe`, but you can pass 37 | // a subscription manager like `sub: new SubsManager({cacheLimit: 20, expireIn: 3})` 38 | this.sub = options.sub || Meteor; 39 | 40 | this.onUnload = options.onUnload; 41 | this.allow = options.allow; 42 | this.allowFields = options.allowFields; 43 | this.changeSelector = options.changeSelector; 44 | this.throttleRefresh = options.throttleRefresh; 45 | this.alternativeCount = options.alternativeCount; 46 | this.skipCount = options.skipCount; 47 | this.searchCustom = options.searchCustom; 48 | this.searchExtraFields = options.searchExtraFields; 49 | 50 | if (_.isArray(options.extraFields)) { 51 | const fields = {}; 52 | _.each(options.extraFields, (fieldName) => { 53 | fields[fieldName] = 1; 54 | }); 55 | this.extraFields = fields; 56 | } 57 | 58 | this.selector = options.selector; 59 | this.options = _.omit( 60 | options, 61 | 'collection', 62 | 'pub', 63 | 'sub', 64 | 'onUnload', 65 | 'allow', 66 | 'allowFields', 67 | 'changeSelector', 68 | 'searchCustom', 69 | 'throttleRefresh', 70 | 'extraFields', 71 | 'searchExtraFields', 72 | 'alternativeCount', 73 | 'skipCount', 74 | 'name', 75 | 'selector' 76 | ); 77 | 78 | Tabular.tablesByName[this.name] = this; 79 | } 80 | }; 81 | 82 | export default Tabular; 83 | -------------------------------------------------------------------------------- /tests/mongoDBQuery.js: -------------------------------------------------------------------------------- 1 | // 2 | // Integration Testing (should be basic case of createRegExp): 3 | // 4 | // Most Basic Test 5 | Tinytest.add('Util createMongoDBQuery - Single Column', function (test) { 6 | var SpacedClassList = ["one"] 7 | var searchString = 'TestSearch' 8 | var BothCols = GenerateBothColumns(SpacedClassList) 9 | // var Output = Util.createMongoDBQuery(BothCols.ExpectedOutput) 10 | var Output = Util.createMongoDBQuery({}, searchString, {}, true, true, BothCols.ExpectedOutput) 11 | var ExpectedOutput = { 12 | "$and":[ 13 | { }, 14 | { 15 | "$or":[ 16 | { 17 | "one":{ 18 | "$regex":"TestSearch", 19 | "$options":"i" 20 | } 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | LogResults(BothCols.columns, ExpectedOutput, Output, test) 27 | }) 28 | // Multiple Query - More Complicated 29 | Tinytest.add('Util createMongoDBQuery - Multiple Query', function (test) { 30 | var SpacedClassList = ["one two"] 31 | var searchString = 'TestSearch' 32 | var BothCols = GenerateBothColumns(SpacedClassList) 33 | var Output = Util.createMongoDBQuery({}, searchString, {}, 34 | true, true, BothCols.ExpectedOutput) 35 | 36 | var ExpectedOutput = { 37 | "$and":[ 38 | { }, 39 | { 40 | "$or":[ 41 | { 42 | "one":{ 43 | "$regex":"TestSearch", 44 | "$options":"i" 45 | } 46 | }, 47 | { 48 | "two":{ 49 | "$regex":"TestSearch", 50 | "$options":"i" 51 | } 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | LogResults(BothCols.ExpectedOutput, ExpectedOutput, Output, test) 58 | }) 59 | // With Existing Selector - Much More Complicated 60 | Tinytest.add('Util createMongoDBQuery - Existing Selector', function (test) { 61 | var SpacedClassList = ["one"] 62 | var searchString = 'TestSearch' 63 | var BothCols = GenerateBothColumns(SpacedClassList) 64 | var selector = { 65 | "$and":[ 66 | { }, 67 | { 68 | "$or":[ 69 | { 70 | "two":{ 71 | "$regex":"TestSearch", 72 | "$options":"i" 73 | } 74 | } 75 | ] 76 | } 77 | ] 78 | } 79 | var Output = Util.createMongoDBQuery(selector, searchString, {}, 80 | true, true, BothCols.ExpectedOutput) 81 | 82 | var ExpectedOutput = { 83 | "$and":[ 84 | { 85 | "$and":[ 86 | { }, 87 | { 88 | "$or":[ 89 | { 90 | "two":{ 91 | "$regex":"TestSearch", 92 | "$options":"i" 93 | } 94 | } 95 | ] 96 | } 97 | ] 98 | }, 99 | { 100 | "$or":[ 101 | { 102 | "one":{ 103 | "$regex":"TestSearch", 104 | "$options":"i" 105 | } 106 | } 107 | ] 108 | } 109 | ] 110 | } 111 | LogResults(BothCols.ExpectedOutput, ExpectedOutput, Output, test) 112 | }) 113 | // With Specified Columns - Much Much More Useful to User 114 | Tinytest.add('Util createMongoDBQuery - Specified Columns', function (test) { 115 | var SpacedClassList = ["three", "four"] 116 | var searchString = 'TestSearch' 117 | // This must be an object and not an array: 118 | var Input = createRegExpField(SpacedClassList, searchString, {}) 119 | var Output = Util.createMongoDBQuery({}, searchString, 120 | Input, true, true, {}) 121 | var ExpectedOutput = { 122 | "$and":[ 123 | { 124 | 125 | }, 126 | { 127 | "$or":[ 128 | { 129 | "three":{ 130 | "$regex":"TestSearch", 131 | "$options":"i" 132 | } 133 | }, 134 | { 135 | "four":{ 136 | "$regex":"TestSearch", 137 | "$options":"i" 138 | } 139 | } 140 | ] 141 | } 142 | ] 143 | } 144 | LogResults(Input, ExpectedOutput, Output, test) 145 | }) 146 | -------------------------------------------------------------------------------- /client/getPubSelector.js: -------------------------------------------------------------------------------- 1 | import { _ } from 'meteor/underscore'; 2 | 3 | function getPubSelector( 4 | selector, 5 | searchString, 6 | searchFields, 7 | searchCaseInsensitive, 8 | splitSearchByWhitespace, 9 | columns, 10 | tableColumns 11 | ) { 12 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 13 | // if search was invoked via .columns().search(), build a query off that 14 | // https://datatables.net/reference/api/columns().search() 15 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 16 | let searchColumns = _.filter(columns, (column) => { 17 | return column.search && column.search.value !== ''; 18 | }); 19 | 20 | // required args 21 | if ((!searchString && searchColumns.length === 0) || !searchFields || searchFields.length === 0) { 22 | return selector; 23 | } 24 | 25 | if (searchColumns.length === 0) { 26 | // normalize search fields array to mirror the structure 27 | // as passed by the datatables ajax.data function 28 | searchColumns = _.map(searchFields, (field) => { 29 | return { 30 | data: field, 31 | search: { 32 | value: searchString, 33 | }, 34 | }; 35 | }); 36 | } 37 | 38 | return createMongoSearchQuery( 39 | selector, 40 | searchString, 41 | searchColumns, 42 | searchCaseInsensitive, 43 | splitSearchByWhitespace, 44 | columns, 45 | tableColumns 46 | ); 47 | } 48 | 49 | function createMongoSearchQuery( 50 | selector, 51 | searchString, 52 | searchColumns, 53 | searchCaseInsensitive, 54 | splitSearchByWhitespace, 55 | columns, 56 | tableColumns 57 | ) { 58 | // See if we can resolve the search string to a number, 59 | // in which case we use an extra query because $regex 60 | // matches string fields only. 61 | const searches = []; 62 | 63 | _.each(searchColumns, (field) => { 64 | // Get the column options from the Tabular.Table so we can check search options 65 | const column = _.findWhere(tableColumns, { data: field.data }); 66 | const exactSearch = column && column.search && column.search.exact; 67 | const numberSearch = column && column.search && column.search.isNumber; 68 | 69 | let searchValue = field.search.value || ''; 70 | 71 | // Split and OR by whitespace, as per default DataTables search behavior 72 | if (splitSearchByWhitespace && !exactSearch) { 73 | searchValue = searchValue.match(/\S+/g); 74 | } else { 75 | searchValue = [searchValue]; 76 | } 77 | 78 | _.each(searchValue, (searchTerm) => { 79 | // String search 80 | if (exactSearch) { 81 | if (numberSearch) { 82 | const searchTermAsNumber = Number(searchTerm); 83 | if (!isNaN(searchTermAsNumber)) { 84 | searches.push({ [field.data]: searchTermAsNumber }); 85 | } else { 86 | searches.push({ [field.data]: searchTerm }); 87 | } 88 | } else { 89 | searches.push({ [field.data]: searchTerm }); 90 | } 91 | } else { 92 | const searchObj = { $regex: searchTerm }; 93 | 94 | // DataTables searches are case insensitive by default 95 | if (searchCaseInsensitive !== false) searchObj.$options = 'i'; 96 | 97 | searches.push({ [field.data]: searchObj }); 98 | 99 | // For backwards compatibility, we do non-exact searches as a number, too, 100 | // even if isNumber isn't true 101 | const searchTermAsNumber = Number(searchTerm); 102 | if (!isNaN(searchTermAsNumber)) { 103 | searches.push({ [field.data]: searchTermAsNumber }); 104 | } 105 | } 106 | }); 107 | }); 108 | 109 | let result; 110 | if (typeof selector === 'object' && selector !== null) { 111 | result = { $and: [selector, { $or: searches }] }; 112 | } else if (searches.length > 1) { 113 | result = { $or: searches }; 114 | } else { 115 | result = searches[0] || {}; 116 | } 117 | 118 | return result; 119 | } 120 | 121 | export default getPubSelector; 122 | -------------------------------------------------------------------------------- /common/util.js: -------------------------------------------------------------------------------- 1 | import { _ } from 'meteor/underscore'; 2 | 3 | export function cleanFieldName(field) { 4 | // for field names with a dot, we just need 5 | // the top level field name 6 | const dot = field.indexOf('.'); 7 | if (dot !== -1) { 8 | field = field.slice(0, dot); 9 | } 10 | 11 | // If it's referencing an array, strip off the brackets 12 | field = field.split('[')[0]; 13 | 14 | return field; 15 | } 16 | 17 | export function cleanFieldNameForSearch(field) { 18 | // Check if object has ["foo"] 19 | if (field.indexOf('"') !== -1) { 20 | console.warn( 21 | `The column data value '${field}' contains a " character and will not be properly parsed for enabling search` 22 | ); 23 | } 24 | // If it's referencing an array, replace the brackets 25 | // This will only work with an object which doesn't have ["foo"] 26 | return field.replace(/\[\w+\]/, ''); 27 | } 28 | 29 | export function sortsAreEqual(oldVal, newVal) { 30 | if (oldVal === newVal) { 31 | return true; 32 | } 33 | let areSame = false; 34 | if (_.isArray(oldVal) && _.isArray(newVal) && oldVal.length === newVal.length) { 35 | areSame = _.every(newVal, function (innerArray, i) { 36 | return innerArray[0] === oldVal[i][0] && innerArray[1] === oldVal[i][1]; 37 | }); 38 | } 39 | return areSame; 40 | } 41 | 42 | export function objectsAreEqual(oldVal, newVal) { 43 | if (oldVal === newVal) { 44 | return true; 45 | } 46 | return JSON.stringify(oldVal) === JSON.stringify(newVal); 47 | } 48 | 49 | // Take the DataTables `order` format and column info 50 | // and convert it into a mongo sort array. 51 | export function getMongoSort(order, columns) { 52 | if (!order || !columns) { 53 | return; 54 | } 55 | 56 | // TODO support the nested arrays format for sort 57 | // and ignore instance functions like "foo()" 58 | const sort = []; 59 | _.each(order, ({ column: colIndex, dir }) => { 60 | const column = columns[colIndex]; 61 | 62 | // Sometimes when swapping out new table columns/collection, this will be called once 63 | // with the old `order` object but the new `columns`. We protect against that here. 64 | if (!column) { 65 | return; 66 | } 67 | 68 | const propName = column.data; 69 | const orderable = column.orderable; 70 | if (typeof propName === 'string' && orderable !== false) { 71 | sort.push([propName, dir]); 72 | } 73 | }); 74 | return sort; 75 | } 76 | 77 | function escapeRegExp(string) { 78 | return string?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 79 | } 80 | 81 | export function getTokens(searchTerm, minTokenLength, maxTokens) { 82 | let tokens = (searchTerm || '') 83 | .trim() 84 | .split(' ') 85 | .filter((token) => token.length >= minTokenLength) 86 | .map((t) => escapeRegExp(t.toLowerCase())); 87 | 88 | if (tokens?.length > maxTokens) { 89 | tokens.splice(maxTokens); 90 | } 91 | return tokens; 92 | } 93 | 94 | export function transformSortArray(sortArray) { 95 | let sortObject = {}; 96 | 97 | sortArray.forEach((item) => { 98 | const [key, direction] = item; 99 | sortObject[key] = direction === 'asc' ? 1 : -1; 100 | }); 101 | 102 | return sortObject; 103 | } 104 | 105 | export function getSearchPaths(tabularTable) { 106 | let columns = 107 | typeof tabularTable.options.columns === 'function' 108 | ? tabularTable.options.columns() 109 | : tabularTable.options.columns || []; 110 | let paths = []; 111 | columns.forEach((column) => { 112 | const data = column.data; 113 | //if it doesn't end with () then it's a field 114 | if (typeof data === 'string' && !data.endsWith('()')) { 115 | // DataTables says default value for col.searchable is `true`, 116 | // so we will search on all columns that haven't been set to 117 | // `false`. 118 | if (column.searchable !== false) { 119 | paths.push(cleanFieldNameForSearch(data)); 120 | } 121 | } 122 | }); 123 | if (tabularTable.searchExtraFields) { 124 | paths.push(...tabularTable.searchExtraFields); 125 | } 126 | return paths; 127 | } 128 | -------------------------------------------------------------------------------- /tests/util.js: -------------------------------------------------------------------------------- 1 | // Test really reliable Util functions 2 | Tinytest.add('Util - cleanFieldName', function (test) { 3 | var Input = "Parents.Child[0]" 4 | var ExpectedOutput = "Parents" 5 | var Output = Util.cleanFieldName(Input) 6 | LogResults(Input, ExpectedOutput, Output, test) 7 | }) 8 | Tinytest.add('Util - cleanFieldNameForSearch', function (test) { 9 | var Input = 'Parents.Child[0]' 10 | var ExpectedOutput = "Parents.Child" 11 | var Output = Util.cleanFieldNameForSearch(Input) 12 | LogResults(Input, ExpectedOutput, Output, test) 13 | }) 14 | Tinytest.add('Util - sortsAreEqual', function (test) { 15 | var Input = ["Parents", "Child"] 16 | var ExpectedOutput = false 17 | var Output = Util.sortsAreEqual(Input[0], Input[1]) 18 | LogResults(Input, ExpectedOutput, Output, test) 19 | }) 20 | Tinytest.add('Util - objectsAreEqual', function (test) { 21 | var Input = [{Child: 0}, {Child: 1}] 22 | var ExpectedOutput = false 23 | var Output = Util.objectsAreEqual(Input[0], Input[1]) 24 | LogResults(Input, ExpectedOutput, Output, test) 25 | }) 26 | 27 | 28 | // 29 | // More complex Util Functions 30 | // 31 | Tinytest.add('Util - getMongoSort', function (test) { 32 | // Note sort does not work on columns run through the parseMultiField 33 | // function because the order of the columns in the array changes, 34 | // instead, the first class in a spaced-separated list is used 35 | var SpacedClassList = ["ClassOne", "ClassTwo ClassThree"] 36 | var BothCols = GenerateBothColumns(SpacedClassList) 37 | var order = [{ 38 | column: 1, 39 | dir: 'asc' 40 | }] 41 | // var ExpectedOutput = [["ClassTwo","asc"]] 42 | var ExpectedOutput = [["url","asc"]] 43 | var Output = Util.getMongoSort(order, BothCols.columns) 44 | LogResults(BothCols.columns, ExpectedOutput, Output, test) 45 | }) 46 | 47 | Tinytest.add('Util - parseMultiFieldColumns', function (test) { 48 | var SpacedClassList = ["one two", Fake.sentence([4]), Fake.sentence([5])] 49 | var BothCols = GenerateBothColumns(SpacedClassList) 50 | var Output = Util.parseMultiFieldColumns(BothCols.columns) 51 | LogResults(BothCols.columns, BothCols.ExpectedOutput, Output, test) 52 | }) 53 | 54 | Tinytest.add('Util - createRegExp', function (test) { 55 | // 56 | // Basic Use Case 57 | // 58 | var SpacedClassList = ["ClassOne"] 59 | var searchString = 'TestSearch' 60 | // This must be an object and not an array: 61 | var Input = createRegExpField(SpacedClassList, searchString, {})[0] 62 | 63 | var ExpectedOutput = searchString 64 | var Output = Util.createRegExp(Input, searchString) 65 | LogResults(Input, ExpectedOutput, Output, test) 66 | 67 | // 68 | // Now with a RegExp 69 | // 70 | var PassedOptions = { 71 | regex: ['^\\D', '\\D?', '*'] 72 | // regex: '^\\D' 73 | } 74 | var Input = createRegExpField( 75 | SpacedClassList, 76 | searchString, 77 | PassedOptions 78 | )[0] 79 | var ElasticSearchString = searchString.replace(/(.)/g, '$1'+ 80 | PassedOptions.regex[1]); 81 | var ExpectedOutput = PassedOptions.regex[0]+ 82 | ElasticSearchString+PassedOptions.regex[2] 83 | // var ExpectedOutput = PassedOptions.regex+searchString 84 | // This must be an object and not an array: 85 | var Output = Util.createRegExp(Input, searchString) 86 | LogResults(Input, ExpectedOutput, Output, test) 87 | 88 | // 89 | // With the proposed "limit" term 90 | // Where only the first two letters are searched 91 | // 92 | var PassedOptions = { 93 | regex: ['^\\D', '\\D?', '*', 2] 94 | } 95 | var Input = createRegExpField( 96 | SpacedClassList, 97 | searchString.match(/\D{2}/), 98 | PassedOptions 99 | )[0] 100 | // Take only first two letters 101 | searchString = searchString[0]+searchString[1] 102 | var ElasticSearchString = searchString.replace( 103 | /(.)/g, '$1'+ PassedOptions.regex[1]); 104 | var ExpectedOutput = PassedOptions.regex[0]+ 105 | ElasticSearchString+PassedOptions.regex[2] 106 | // This must be an object and not an array: 107 | var Output = Util.createRegExp(Input, searchString) 108 | LogResults(Input, ExpectedOutput, Output, test) 109 | 110 | // 111 | // Where a third letter is searched (i.e. not searched): 112 | // 113 | searchString = searchString+'3' 114 | var ExpectedOutput = '^@&&@&&@&&@&&@&&@&&@' 115 | var Output = Util.createRegExp(Input, searchString) 116 | LogResults(Input, ExpectedOutput, Output, test) 117 | }) -------------------------------------------------------------------------------- /client/tableInit.js: -------------------------------------------------------------------------------- 1 | import { Blaze } from 'meteor/blaze'; 2 | import { _ } from 'meteor/underscore'; 3 | import { cleanFieldName, cleanFieldNameForSearch } from '../common/util'; 4 | 5 | /** 6 | * Uses the Tabular.Table instance to get the columns, fields, and searchFields 7 | * @param {Tabular.Table} tabularTable The Tabular.Table instance 8 | * @param {Template} template The Template instance 9 | */ 10 | function tableInit(tabularTable, template) { 11 | const fields = {}; 12 | const searchFields = []; 13 | 14 | // Loop through the provided columns object 15 | let columns = tabularTable.options.columns || []; 16 | 17 | if (typeof columns === 'function') { 18 | columns = tabularTable.options.columns(); 19 | } 20 | 21 | columns = columns.map(column => { 22 | const options = { ...column }; 23 | 24 | _.extend(options, templateColumnOptions(template, column)); 25 | 26 | // `templateColumnOptions` might have set defaultContent option. If not, we need it set 27 | // to something to protect against errors from null and undefined values. 28 | if (!options.defaultContent) { 29 | options.defaultContent = column.defaultContent || ''; 30 | } 31 | 32 | _.extend(options, searchAndOrderOptions(column)); 33 | 34 | // Build the list of field names we want included in the publication and in the searching 35 | const data = column.data; 36 | if (typeof data === 'string') { 37 | fields[cleanFieldName(data)] = 1; 38 | 39 | // DataTables says default value for col.searchable is `true`, 40 | // so we will search on all columns that haven't been set to 41 | // `false`. 42 | if (options.searchable !== false) { 43 | searchFields.push(cleanFieldNameForSearch(data)); 44 | } 45 | } 46 | 47 | // If `titleFn` option is provided, we set `title` option to the string 48 | // result of that function. This is done for any extensions that might 49 | // use the title, such as the colvis button. However `Blaze.toHTML` is 50 | // not reactive, so in the `headerCallback` in main.js, we will set the 51 | // actual column header with Blaze.render so that it is reactive. 52 | const titleFunction = options.titleFn; 53 | if (typeof titleFunction === 'function') { 54 | options.title = Blaze.toHTML(new Blaze.View(titleFunction)); 55 | } 56 | 57 | return options; 58 | }); 59 | template.tabular.columns = columns; 60 | template.tabular.fields = fields; 61 | template.tabular.searchFields = searchFields; 62 | 63 | return columns; 64 | } 65 | 66 | // The `tmpl` column option is special for this package. We parse it into other column options 67 | // and then remove it. 68 | function templateColumnOptions(template, { data, render, tmpl, tmplContext }) { 69 | 70 | if (!tmpl) { 71 | return {}; 72 | } 73 | 74 | const options = {}; 75 | 76 | // Cell should be initially blank 77 | options.defaultContent = ''; 78 | 79 | // When the cell is created, render its content from 80 | // the provided template with row data. 81 | options.createdCell = (cell, cellData, rowData) => { 82 | // Allow the table to adjust the template context if desired 83 | if (typeof tmplContext === 'function') { 84 | rowData = tmplContext(rowData); 85 | } 86 | 87 | //this will be called by DT - let's keep track of all blazeviews it makes us create 88 | let view = Blaze.renderWithData(tmpl, rowData, cell); 89 | template.tabular.blazeViews.push(view); 90 | return view; 91 | }; 92 | 93 | // If we're displaying a template for this field and we've also provided data, we want to 94 | // pass the data prop along to DataTables to enable sorting and filtering. 95 | // However, DataTables will then add that data to the displayed cell, which we don't want since 96 | // we're rendering a template there with Blaze. We can prevent this issue by having the "render" 97 | // function return an empty string for display content. 98 | if (data && !render) { 99 | options.render = (data, type) => (type === 'display' ? '' : data); 100 | } 101 | 102 | return options; 103 | } 104 | 105 | // If it's referencing an instance function, don't 106 | // include it. Prevent sorting and searching because 107 | // our pub function won't be able to do it. 108 | function searchAndOrderOptions(column) { 109 | const data = column.data; 110 | if (typeof data === 'string' && data.indexOf('()') !== -1) { 111 | return { orderable: false, searchable: false }; 112 | } 113 | // If there's a Blaze template but not data, then we shouldn't try to allow sorting. It won't work 114 | if (column.tmpl && !data) { 115 | return { orderable: false, searchable: column.searchable }; 116 | } 117 | return { orderable: column.orderable, searchable: column.searchable }; 118 | } 119 | 120 | export default tableInit; 121 | -------------------------------------------------------------------------------- /server/main.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { check, Match } from 'meteor/check'; 3 | import { _ } from 'meteor/underscore'; 4 | import Tabular from '../common/Tabular'; 5 | import { getSearchPaths, getTokens, transformSortArray } from '../common/util'; 6 | 7 | const DEFAULT_MAX_TOKENS = 5; 8 | const DEFAULT_MIN_TOKEN_LENGTH = 3; 9 | 10 | /* 11 | * These are the two publications used by TabularTable. 12 | * 13 | * The genericPub one can be overridden by supplying a `pub` 14 | * property with a different publication name. This publication 15 | * is given only the list of ids and requested fields. You may 16 | * want to override it if you need to publish documents from 17 | * related collections along with the table collection documents. 18 | * 19 | * The getInfo one runs first and handles all the complex logic 20 | * required by this package, so that you don't have to duplicate 21 | * this logic when overriding the genericPub function. 22 | * 23 | * Having two publications also allows fine-grained control of 24 | * reactivity on the client. 25 | */ 26 | 27 | Meteor.publish('tabular_genericPub', function (tableName, ids, fields) { 28 | check(tableName, String); 29 | check(ids, Array); 30 | check(fields, Match.Optional(Object)); 31 | //console.debug( 'ids', ids ); 32 | 33 | const table = Tabular.tablesByName[tableName]; 34 | if (!table) { 35 | // We throw an error in the other pub, so no need to throw one here 36 | this.ready(); 37 | return; 38 | } 39 | 40 | // Check security. We call this in both publications. 41 | if (typeof table.allow === 'function' && !table.allow(this.userId, fields)) { 42 | this.ready(); 43 | return; 44 | } 45 | 46 | // Check security for fields. We call this only in this publication 47 | if (typeof table.allowFields === 'function' && !table.allowFields(this.userId, fields)) { 48 | this.ready(); 49 | return; 50 | } 51 | 52 | return table.collection.find({ _id: { $in: ids } }, { fields: fields }); 53 | }); 54 | 55 | Meteor.publish('tabular_getInfo', async function (tableName, selector, sort, skip, limit, searchTerm) { 56 | check(tableName, String); 57 | check(selector, Match.Optional(Match.OneOf(Object, null))); 58 | check(sort, Match.Optional(Match.OneOf(Array, null))); 59 | check(skip, Number); 60 | check(limit, Match.Optional(Match.OneOf(Number, null))); 61 | check(searchTerm, Match.Optional(Match.OneOf(String, null))); 62 | 63 | const table = Tabular.tablesByName[tableName]; 64 | if (!table) { 65 | throw new Error( 66 | `No TabularTable defined with the name "${tableName}". Make sure you are defining your TabularTable in common code.` 67 | ); 68 | } 69 | 70 | // Check security. We call this in both publications. 71 | // Even though we're only publishing _ids and counts 72 | // from this function, with sensitive data, there is 73 | // a chance someone could do a query and learn something 74 | // just based on whether a result is found or not. 75 | if (typeof table.allow === 'function' && !table.allow(this.userId)) { 76 | this.ready(); 77 | return; 78 | } 79 | 80 | let newSelector = selector || {}; 81 | 82 | // Allow the user to modify the selector before we use it 83 | if (typeof table.changeSelector === 'function') { 84 | newSelector = table.changeSelector(newSelector, this.userId); 85 | } 86 | 87 | // Apply the server side selector specified in the tabular 88 | // table constructor. Both must be met, so we join 89 | // them using $and, allowing both selectors to have 90 | // the same keys. 91 | if (typeof table.selector === 'function') { 92 | const tableSelector = table.selector(this.userId); 93 | if (_.isEmpty(newSelector)) { 94 | newSelector = tableSelector; 95 | } else { 96 | newSelector = { $and: [tableSelector, newSelector] }; 97 | } 98 | } 99 | 100 | const findOptions = { 101 | skip: skip, 102 | fields: { _id: 1 } 103 | }; 104 | 105 | // `limit` may be `null` 106 | if (limit > 0) { 107 | findOptions.limit = table.skipCount ? limit + 1 : limit; 108 | } 109 | 110 | // `sort` may be `null` 111 | if (_.isArray(sort)) { 112 | findOptions.sort = sort; 113 | } 114 | let filteredCursor; 115 | let filteredRecordIds = []; // so that the observer doesn't complain while the promise is not resolved 116 | let countCursor; 117 | let tokens = getTokens( 118 | searchTerm, 119 | table.options.searchMinTokenLength || DEFAULT_MIN_TOKEN_LENGTH, 120 | table.options.searchMaxTokens || DEFAULT_MAX_TOKENS 121 | ); 122 | //only enter this path if we really have a search to perform 123 | if (tokens?.length && typeof table.searchCustom === 'function') { 124 | const paths = getSearchPaths(table); 125 | const newSort = transformSortArray(findOptions.sort); 126 | 127 | filteredRecordIds = table.searchCustom( 128 | this.userId, 129 | newSelector, 130 | tokens, 131 | paths, 132 | newSort, 133 | skip, 134 | limit 135 | ); 136 | } else { 137 | filteredCursor = table.collection.find(newSelector, findOptions); 138 | filteredRecordIds = await filteredCursor.mapAsync((doc) => doc._id); 139 | countCursor = table.collection.find(newSelector, { fields: { _id: 1 } }); 140 | } 141 | let fakeCount; 142 | //if the number of results is greater than the limit then we need to remove the last one 143 | //and set the fake count to the limit + skip 144 | //limit can be null - so don't process it if it is 145 | if (limit && filteredRecordIds.length > limit) { 146 | //keep only first $limit records in filteredRecordIds 147 | fakeCount = filteredRecordIds.length + skip; 148 | filteredRecordIds.splice(limit, filteredRecordIds.length - limit); 149 | } else { 150 | fakeCount = filteredRecordIds.length + skip; 151 | } 152 | let recordReady = false; 153 | let updateRecords = async () => { 154 | let currentCount; 155 | if (!table.skipCount) { 156 | if (typeof table.alternativeCount === 'function') { 157 | currentCount = await table.alternativeCount(newSelector); 158 | } else { 159 | currentCount = countCursor ? await countCursor.countAsync() : fakeCount; 160 | } 161 | } 162 | 163 | // From https://datatables.net/manual/server-side 164 | // recordsTotal: Total records, before filtering (i.e. the total number of records in the database) 165 | // recordsFiltered: Total records, after filtering (i.e. the total number of records after filtering has been applied - not just the number of records being returned for this page of data). 166 | // happens that this first getInfo publication may return duplicate ids 167 | // and this explain the lack of reactivity on changes that some have reported (because the second publication only returns once when first has asked twice) 168 | // so https://stackoverflow.com/questions/9229645/remove-duplicate-values-from-js-array 169 | 170 | const record = { 171 | ids: [ ...new Set( filteredRecordIds )], 172 | // count() will give us the updated total count 173 | // every time. It does not take the find options 174 | // limit into account. 175 | recordsTotal: table.skipCount ? fakeCount : currentCount, 176 | recordsFiltered: table.skipCount ? fakeCount : currentCount 177 | }; 178 | 179 | if (recordReady) { 180 | //console.log('changed', tableName, record); 181 | this.changed('tabular_records', tableName, record); 182 | } else { 183 | //console.log('added', tableName, record); 184 | this.added('tabular_records', tableName, record); 185 | recordReady = true; 186 | } 187 | }; 188 | 189 | if (table.throttleRefresh) { 190 | // Why Meteor.bindEnvironment? See https://github.com/aldeed/meteor-tabular/issues/278#issuecomment-217318112 191 | updateRecords = _.throttle(Meteor.bindEnvironment(updateRecords), table.throttleRefresh); 192 | } 193 | 194 | await updateRecords(); 195 | 196 | this.ready(); 197 | 198 | // Handle docs being added or removed from the result set. 199 | let initializing = true; 200 | const handle = filteredCursor?.observeChanges({ 201 | added: function (id) { 202 | if (initializing) { 203 | return; 204 | } 205 | //console.log('ADDED'); 206 | filteredRecordIds.push(id); 207 | }, 208 | removed: async function (id) { 209 | //console.log('REMOVED'); 210 | // _.findWhere is used to support Mongo ObjectIDs 211 | filteredRecordIds = 212 | typeof id === 'string' 213 | ? (filteredRecordIds = _.without(filteredRecordIds, id)) 214 | : (filteredRecordIds = _.without(filteredRecordIds, _.findWhere(filteredRecordIds, id))); 215 | await updateRecords(); 216 | } 217 | }); 218 | initializing = false; 219 | 220 | // It is too inefficient to use an observe without any limits to track count perfectly 221 | // accurately when, for example, the selector is {} and there are a million documents. 222 | // Instead we will update the count every 10 seconds, in addition to whenever the limited 223 | // result set changes. 224 | const interval = Meteor.setInterval(updateRecords, 10000); 225 | 226 | // Stop observing the cursors when client unsubs. 227 | // Stopping a subscription automatically takes 228 | // care of sending the client any removed messages. 229 | this.onStop(() => { 230 | Meteor.clearInterval(interval); 231 | handle?.then(( h ) => { h.stop(); }); 232 | }); 233 | }); 234 | 235 | export default Tabular; 236 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import './tabular.html'; 2 | 3 | /* global _, Blaze, Tracker, ReactiveVar, Session, Meteor, */ 4 | import { $ } from 'meteor/jquery'; 5 | //This is a bit shit that we're initialising this explicit version within the library 6 | import 'datatables.net-bs5'; 7 | 8 | import { Mongo } from 'meteor/mongo'; 9 | import { Template } from 'meteor/templating'; 10 | 11 | import Tabular from '../common/Tabular'; 12 | import tableInit from './tableInit'; 13 | import getPubSelector from './getPubSelector'; 14 | import { getMongoSort, objectsAreEqual, sortsAreEqual } from '../common/util'; 15 | 16 | //dataTableInit(window, $); 17 | Template.registerHelper('TabularTables', Tabular.tablesByName); 18 | Tabular.tableRecords = new Mongo.Collection('tabular_records'); 19 | Tabular.remoteTableRecords = []; 20 | 21 | Tabular.getTableRecordsCollection = function (connection) { 22 | if (!connection || connection === Tabular.tableRecords._connection) { 23 | return Tabular.tableRecords; 24 | } 25 | 26 | let remote = _.find(Tabular.remoteTableRecords, (remote) => remote.connection === connection); 27 | if (!remote) { 28 | remote = { 29 | connection, 30 | tableRecords: new Mongo.Collection('tabular_records', { connection }) 31 | }; 32 | Tabular.remoteTableRecords.push(remote); 33 | } 34 | return remote.tableRecords; 35 | }; 36 | 37 | Tabular.getRecord = function (name, collection) { 38 | return Tabular.getTableRecordsCollection(collection._connection).findOne(name); 39 | }; 40 | 41 | Template.tabular.helpers({ 42 | atts() { 43 | // We remove the "table" and "selector" attributes and assume the rest belong 44 | // on the element 45 | return _.omit(this, 'table', 'selector'); 46 | } 47 | }); 48 | 49 | Template.tabular.onRendered(function () { 50 | const template = this; 51 | template.$tableElement = template.$('table'); 52 | let table; 53 | let resetTablePaging = false; 54 | 55 | template.tabular = {}; 56 | template.tabular.data = []; 57 | template.tabular.pubSelector = new ReactiveVar({}, objectsAreEqual); 58 | template.tabular.skip = new ReactiveVar(0); 59 | template.tabular.limit = new ReactiveVar(10); 60 | template.tabular.sort = new ReactiveVar(null, sortsAreEqual); 61 | template.tabular.columns = null; 62 | template.tabular.fields = null; 63 | template.tabular.searchFields = null; 64 | template.tabular.searchCaseInsensitive = true; 65 | template.tabular.splitSearchByWhitespace = true; 66 | template.tabular.tableName = new ReactiveVar(null); 67 | template.tabular.options = new ReactiveVar({}, objectsAreEqual); 68 | template.tabular.docPub = new ReactiveVar(null); 69 | template.tabular.collection = new ReactiveVar(null); 70 | template.tabular.connection = null; 71 | template.tabular.ready = new ReactiveVar(false); 72 | template.tabular.recordsTotal = 0; 73 | template.tabular.recordsFiltered = 0; 74 | template.tabular.isLoading = new ReactiveVar(true); 75 | template.tabular.blazeViews = []; 76 | template.tabular.searchTerm = new ReactiveVar(this.data.searchTerm || null); 77 | 78 | // These are some DataTables options that we need for everything to work. 79 | // We add them to the options specified by the user. 80 | const ajaxOptions = { 81 | // tell DataTables that we're getting the table data from a server 82 | serverSide: true, 83 | processing: true, 84 | // define the function that DataTables will call upon first load and whenever 85 | // we tell it to reload data, such as when paging, etc. 86 | ajax: function (data, callback /*, settings*/) { 87 | // When DataTables requests data, first we set 88 | // the new skip, limit, order, and pubSelector values 89 | // that DataTables has requested. These trigger 90 | // the first subscription, which will then trigger the 91 | // second subscription. 92 | 93 | //console.log( 'data', data, 'template.tabular.data', template.tabular.data ); 94 | 95 | // Update skip 96 | template.tabular.skip.set(data.start); 97 | Session.set('Tabular.LastSkip', data.start); 98 | 99 | // Update limit 100 | let options = template.tabular.options.get(); 101 | let hardLimit = options && options.limit; 102 | if (data.length === -1) { 103 | if (hardLimit === undefined) { 104 | console.warn( 105 | 'When using no paging or an "All" option with tabular, it is best to also add a hard limit in your table options like {limit: 500}' 106 | ); 107 | template.tabular.limit.set(null); 108 | } else { 109 | template.tabular.limit.set(hardLimit); 110 | } 111 | } else { 112 | template.tabular.limit.set(data.length); 113 | } 114 | 115 | // Update sort 116 | template.tabular.sort.set(getMongoSort(data.order, options.columns)); 117 | 118 | // Update pubSelector 119 | let pubSelector = template.tabular.selector; 120 | //if we're using the searchCustom functionality don't do the default client side regex via getPubSelector 121 | if (!template.tabular.tableDef.searchCustom) { 122 | pubSelector = getPubSelector( 123 | template.tabular.selector, 124 | (data.search && data.search.value) || null, 125 | template.tabular.searchFields, 126 | template.tabular.searchCaseInsensitive, 127 | template.tabular.splitSearchByWhitespace, 128 | data.columns || null, 129 | options.columns 130 | ); 131 | } 132 | template.tabular.pubSelector.set(pubSelector); 133 | 134 | // We're ready to subscribe to the data. 135 | // Matters on the first run only. 136 | template.tabular.ready.set(true); 137 | 138 | //console.log('ajax'); 139 | //console.debug( 'calling ajax callback with', template.tabular.data ); 140 | 141 | callback({ 142 | draw: data.draw, 143 | recordsTotal: template.tabular.recordsTotal, 144 | recordsFiltered: template.tabular.recordsFiltered, 145 | data: template.tabular.data 146 | }); 147 | }, 148 | initComplete: function () { 149 | // Fix THOMAS modified 24.11.2021 150 | // Fix the case of multiple table on the same page 151 | const tableId = template.data.id; 152 | const options = template.tabular.options.get(); 153 | if (options.search && options.search.onEnterOnly) { 154 | const replaceSearchLabel = function (newText) { 155 | $('#' + tableId + '_filter label') 156 | .contents() 157 | .filter(function () { 158 | return this.nodeType === 3 && this.textContent.trim().length; 159 | }) 160 | .replaceWith(newText); 161 | }; 162 | $('#' + tableId + '_filter input') 163 | .unbind() 164 | .bind('keyup change', function (event) { 165 | if (!table) return; 166 | if (event.keyCode === 13 || this.value === '') { 167 | replaceSearchLabel(table.i18n('search')); 168 | table.search(this.value).draw(); 169 | } else { 170 | replaceSearchLabel(table.i18n('Press enter to filter')); 171 | } 172 | }); 173 | } 174 | }, 175 | headerCallback(headerRow) { 176 | const options = template.tabular.options.get(); 177 | const columns = options.columns; 178 | 179 | $(headerRow) 180 | .find('td,th') 181 | .each((index, headerCell) => { 182 | const titleFunction = columns[index] && columns[index].titleFn; 183 | if (typeof titleFunction === 'function') { 184 | headerCell.innerHTML = ''; 185 | if (headerCell.__blazeViewInstance) { 186 | Blaze.remove(headerCell.__blazeViewInstance); 187 | } 188 | const view = new Blaze.View(titleFunction); 189 | headerCell.__blazeViewInstance = Blaze.render(view, headerCell); 190 | } 191 | }); 192 | } 193 | }; 194 | 195 | // For testing 196 | //setUpTestingAutoRunLogging(template); 197 | 198 | // Reactively determine table columns, fields, and searchFields. 199 | // This will rerun whenever the current template data changes. 200 | let lastTableName; 201 | template.autorun(function () { 202 | let data = Template.currentData(); 203 | 204 | //console.log('currentData autorun', data); 205 | 206 | // if we don't have data OR the selector didn't actually change return out 207 | if (!data || (data.selector && template.tabular.selector === data.selector)) { 208 | return; 209 | } 210 | 211 | // We get the current TabularTable instance, and cache it on the 212 | // template instance for access elsewhere 213 | let tabularTable = (template.tabular.tableDef = data.table); 214 | 215 | if (!(tabularTable instanceof Tabular.Table)) { 216 | throw new Error('You must pass Tabular.Table instance as the table attribute'); 217 | } 218 | 219 | // Always update the selector reactively 220 | template.tabular.selector = data.selector; 221 | template.tabular.searchTerm.set(data.searchTerm || null); 222 | 223 | // The remaining stuff relates to changing the `table` 224 | // attribute. If we didn't change it, we can stop here, 225 | // but we need to reload the table if this is not the first 226 | // run 227 | if (tabularTable.name === lastTableName) { 228 | if (table) { 229 | // passing `false` as the second arg tells it to 230 | // reset the paging 231 | table.ajax.reload(null, true); 232 | } 233 | return; 234 | } 235 | 236 | // If we reactively changed the `table` attribute, run 237 | // onUnload for the previous table 238 | if (lastTableName !== undefined) { 239 | let lastTableDef = Tabular.tablesByName[lastTableName]; 240 | if (lastTableDef && typeof lastTableDef.onUnload === 'function') { 241 | lastTableDef.onUnload(); 242 | } 243 | } 244 | 245 | // Cache this table name as the last table name for next run 246 | lastTableName = tabularTable.name; 247 | 248 | // Figure out and update the columns, fields, and searchFields 249 | const columns = tableInit(tabularTable, template); 250 | 251 | // Set/update everything else 252 | template.tabular.searchCaseInsensitive = true; 253 | template.tabular.splitSearchByWhitespace = true; 254 | 255 | if (tabularTable.options && tabularTable.options.search) { 256 | if (tabularTable.options.search.caseInsensitive === false) { 257 | template.tabular.searchCaseInsensitive = false; 258 | } 259 | if (tabularTable.options.search.smart === false) { 260 | template.tabular.splitSearchByWhitespace = false; 261 | } 262 | } 263 | template.tabular.options.set({ 264 | ...tabularTable.options, 265 | columns 266 | }); 267 | template.tabular.tableName.set(tabularTable.name); 268 | template.tabular.docPub.set(tabularTable.pub); 269 | template.tabular.collection.set(tabularTable.collection); 270 | if (tabularTable.collection && tabularTable.collection._connection) { 271 | template.tabular.connection = tabularTable.collection._connection; 272 | } 273 | 274 | // userOptions rerun should do this? 275 | if (table) { 276 | // passing `true` as the second arg tells it to 277 | // reset the paging 278 | table.ajax.reload(null, true); 279 | } 280 | }); 281 | 282 | template.autorun(() => { 283 | // these 5 are the parameters passed to "tabular_getInfo" subscription 284 | // so when they *change*, set the isLoading flag to true 285 | template.tabular.tableName.get(); 286 | template.tabular.pubSelector.get(); 287 | template.tabular.sort.get(); 288 | template.tabular.skip.get(); 289 | template.tabular.limit.get(); 290 | template.tabular.isLoading.set(true); 291 | template.tabular.searchTerm.get(); 292 | }); 293 | 294 | // First Subscription 295 | // Subscribe to an array of _ids that should be on the 296 | // current page of the table, plus some aggregate 297 | // numbers that DataTables needs in order to show the paging. 298 | // The server will reactively keep this info accurate. 299 | // It's not necessary to call stop 300 | // on subscriptions that are within autorun computations. 301 | template.autorun(function () { 302 | if (!template.tabular.ready.get()) { 303 | return; 304 | } 305 | 306 | //console.log('tabular_getInfo autorun'); 307 | 308 | function onReady() { 309 | template.tabular.isLoading.set(false); 310 | } 311 | 312 | let connection = template.tabular.connection; 313 | let context = connection || Meteor; 314 | context.subscribe( 315 | 'tabular_getInfo', 316 | template.tabular.tableName.get(), 317 | template.tabular.pubSelector.get(), 318 | template.tabular.sort.get(), 319 | template.tabular.skip.get(), 320 | template.tabular.limit.get(), 321 | template.tabular.searchTerm.get(), 322 | onReady 323 | ); 324 | }); 325 | 326 | // Second Subscription 327 | // Reactively subscribe to the documents with _ids given to us. Limit the 328 | // fields to only those we need to display. It's not necessary to call stop 329 | // on subscriptions that are within autorun computations. 330 | template.autorun(function () { 331 | // tableInfo is reactive and causes a rerun whenever the 332 | // list of docs that should currently be in the table changes. 333 | // It does not cause reruns based on the documents themselves 334 | // changing. 335 | let tableName = template.tabular.tableName.get(); 336 | let collection = template.tabular.collection.get(); 337 | let tableInfo = Tabular.getRecord(tableName, collection) || {}; 338 | 339 | //console.log('tableName and tableInfo autorun', tableName, tableInfo); 340 | 341 | template.tabular.recordsTotal = tableInfo.recordsTotal || 0; 342 | template.tabular.recordsFiltered = tableInfo.recordsFiltered || 0; 343 | 344 | // In some cases, there is no point in subscribing to nothing 345 | if ( 346 | _.isEmpty(tableInfo) || 347 | template.tabular.recordsTotal === 0 || 348 | template.tabular.recordsFiltered === 0 349 | ) { 350 | return; 351 | } 352 | 353 | // Extend with extraFields from table definition 354 | let fields = template.tabular.fields; 355 | if (fields) { 356 | // Extend with extraFields from table definition 357 | if (typeof template.tabular.tableDef.extraFields === 'object') { 358 | fields = _.extend(_.clone(fields), template.tabular.tableDef.extraFields); 359 | } 360 | } 361 | 362 | template.tabular.tableDef.sub.subscribe( 363 | template.tabular.docPub.get(), 364 | tableName, 365 | tableInfo.ids || [], 366 | fields 367 | ); 368 | }); 369 | 370 | // Build the table. We rerun this only when the table 371 | // options specified by the user changes, which should be 372 | // only when the `table` attribute changes reactively. 373 | template.autorun((c) => { 374 | const userOptions = template.tabular.options.get(); 375 | const options = _.extend({}, ajaxOptions, userOptions); 376 | 377 | //console.log('userOptions autorun', userOptions); 378 | 379 | // unless the user provides her own displayStart, 380 | // we use a value from Session. This keeps the 381 | // same page selected after a hot code push. 382 | if (c.firstRun && !('displayStart' in options)) { 383 | options.displayStart = Tracker.nonreactive(function () { 384 | return Session.get('Tabular.LastSkip'); 385 | }); 386 | } 387 | 388 | if (!('order' in options)) { 389 | options.order = []; 390 | } 391 | 392 | // After the first time, we need to destroy before rebuilding. 393 | if (table) { 394 | let dt = template.$tableElement.DataTable(); 395 | if (dt) { 396 | dt.destroy(); 397 | } 398 | template.$tableElement.empty(); 399 | } 400 | 401 | // We start with an empty table. 402 | // Data will be populated by ajax function now. 403 | table = template.$tableElement.DataTable(options); 404 | 405 | if (options.buttonContainer) { 406 | const container = $(options.buttonContainer, table.table().container()); 407 | table.buttons().container().appendTo(container); 408 | } 409 | }); 410 | 411 | template.autorun(() => { 412 | // Get table name non-reactively 413 | let tableName = Tracker.nonreactive(function () { 414 | return template.tabular.tableName.get(); 415 | }); 416 | // Get the collection that we're showing in the table non-reactively 417 | let collection = Tracker.nonreactive(function () { 418 | return template.tabular.collection.get(); 419 | }); 420 | 421 | // React when the requested list of records changes. 422 | // This can happen for various reasons. 423 | // * DataTables reran ajax due to sort changing. 424 | // * DataTables reran ajax due to page changing. 425 | // * DataTables reran ajax due to results-per-page changing. 426 | // * DataTables reran ajax due to search terms changing. 427 | // * `selector` attribute changed reactively 428 | // * Docs were added/changed/removed by this user or 429 | // another user, causing visible result set to change. 430 | let tableInfo = Tabular.getRecord(tableName, collection); 431 | if (!collection || !tableInfo) { 432 | return; 433 | } 434 | 435 | // Build options object to pass to `find`. 436 | // It's important that we use the same options 437 | // that were used in generating the list of `_id`s 438 | // on the server. 439 | let findOptions = {}; 440 | let fields = template.tabular.fields; 441 | if (fields) { 442 | // Extend with extraFields from table definition 443 | if (typeof template.tabular.tableDef.extraFields === 'object') { 444 | _.extend(fields, template.tabular.tableDef.extraFields); 445 | } 446 | findOptions.fields = fields; 447 | } 448 | 449 | // Sort does not need to be reactive here; using 450 | // reactive sort would result in extra rerunning. 451 | let sort = Tracker.nonreactive(function () { 452 | return template.tabular.sort.get(); 453 | }); 454 | if (sort) { 455 | findOptions.sort = sort; 456 | } 457 | 458 | // Get the updated list of docs we should be showing 459 | let cursor = collection.find({ _id: { $in: tableInfo.ids } }, findOptions); 460 | 461 | //console.log('tableInfo, fields, sort, find autorun', cursor.count()); 462 | //console.log( 'autorun: cursor.count', cursor.count(), 'tableInfo.ids.length', tableInfo.ids.length ); 463 | 464 | // We're subscribing to the docs just in time, so there's 465 | // a good chance that they aren't all sent to the client yet. 466 | // We'll stop here if we didn't find all the docs we asked for. 467 | // This will rerun one or more times as the docs are received 468 | // from the server, and eventually we'll have them all. 469 | // Without this check in here, there's a lot of flashing in the 470 | // table as rows are added. 471 | if (cursor.count() < tableInfo.ids.length) { 472 | return; 473 | } 474 | // Get data as array for DataTables to consume in the ajax function 475 | template.tabular.data = cursor.fetch(); 476 | 477 | if (template.tabular.blazeViews) { 478 | //console.log(`Removing ${template.blazeViews.length}`); 479 | template.tabular.blazeViews.forEach(view => { 480 | try { 481 | Blaze.remove(view); 482 | } 483 | catch(err) { 484 | console.error(err); 485 | } 486 | }); 487 | template.tabular.blazeViews = []; 488 | } 489 | 490 | // For these types of reactive changes, we don't want to 491 | // reset the page we're on, so we pass `false` as second arg. 492 | // The exception is if we changed the results-per-page number, 493 | // in which cases `resetTablePaging` will be `true` and we will do so. 494 | if (table) { 495 | if (resetTablePaging) { 496 | table.ajax.reload(null, true); 497 | resetTablePaging = false; 498 | } else { 499 | table.ajax.reload(null, false); 500 | } 501 | } 502 | 503 | template.tabular.isLoading.set(false); 504 | }); 505 | 506 | template.autorun(() => { 507 | const isLoading = template.tabular.isLoading.get(); 508 | if (isLoading) { 509 | template.$('.dataTables_processing').show(); 510 | } else { 511 | template.$('.dataTables_processing').hide(); 512 | } 513 | }); 514 | 515 | // force table paging to reset to first page when we change page length 516 | template.$tableElement.on('length.dt', function () { 517 | resetTablePaging = true; 518 | }); 519 | }); 520 | 521 | Template.tabular.onDestroyed(function () { 522 | // Clear last skip tracking 523 | Session.set('Tabular.LastSkip', 0); 524 | // Run a user-provided onUnload function 525 | if ( 526 | this.tabular && 527 | this.tabular.tableDef && 528 | typeof this.tabular.tableDef.onUnload === 'function' 529 | ) { 530 | this.tabular.tableDef.onUnload(); 531 | } 532 | 533 | if (this.tabular?.blazeViews) { 534 | //console.log(`Removing ${this.blazeViews.length}`); 535 | this.tabular.blazeViews.forEach(view => { 536 | try { 537 | Blaze.remove(view); 538 | } 539 | catch(err) { 540 | console.error(err); 541 | } 542 | }); 543 | this.tabular.blazeViews = []; 544 | } 545 | 546 | // Destroy the DataTable instance to avoid memory leak 547 | if (this.$tableElement && this.$tableElement.length) { 548 | const dt = this.$tableElement.DataTable(); 549 | if (dt) { 550 | dt.destroy(); 551 | } 552 | this.$tableElement.empty(); 553 | } 554 | }); 555 | 556 | //function setUpTestingAutoRunLogging(template) { 557 | // template.autorun(function () { 558 | // var val = template.tabular.tableName.get(); 559 | // console.log('tableName changed', val); 560 | // }); 561 | // 562 | // template.autorun(function () { 563 | // var val = template.tabular.pubSelector.get(); 564 | // console.log('pubSelector changed', val); 565 | // }); 566 | // 567 | // template.autorun(function () { 568 | // var val = template.tabular.sort.get(); 569 | // console.log('sort changed', val); 570 | // }); 571 | // 572 | // template.autorun(function () { 573 | // var val = template.tabular.skip.get(); 574 | // console.log('skip changed', val); 575 | // }); 576 | // 577 | // template.autorun(function () { 578 | // var val = template.tabular.limit.get(); 579 | // console.log('limit changed', val); 580 | // }); 581 | //} 582 | 583 | export default Tabular; 584 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | aldeed:tabular 2 | ========================= 3 | 4 | A Meteor package that creates reactive [DataTables](http://datatables.net/) in an efficient way, allowing you to display the contents of enormous collections without impacting app performance. 5 | 6 | 7 | ## !!! MAINTAINERS WANTED !!! 8 | 9 | Please open an issue if you like to help out with maintenance on this package. 10 | 11 | 12 | ## Table of Contents 13 | 14 | 15 | 16 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 17 | 18 | - [Features](#features) 19 | - [Installation](#installation) 20 | - [Installing and Configuring a Theme](#installing-and-configuring-a-theme) 21 | - [Online Demo App](#online-demo-app) 22 | - [Example](#example) 23 | - [Displaying Only Part of a Collection's Data Set](#displaying-only-part-of-a-collections-data-set) 24 | - [Passing Options to the DataTable](#passing-options-to-the-datatable) 25 | - [Template Cells](#template-cells) 26 | - [Searching](#searching) 27 | - [Customizing Search Behavior](#customizing-search-behavior) 28 | - [Using Collection Helpers](#using-collection-helpers) 29 | - [Publishing Extra Fields](#publishing-extra-fields) 30 | - [Modifying the Selector](#modifying-the-selector) 31 | - [Saving state](#saving-state) 32 | - [Security](#security) 33 | - [Caching the Documents](#caching-the-documents) 34 | - [Hooks](#hooks) 35 | - [Rendering a responsive table](#rendering-a-responsive-table) 36 | - [Active Datasets](#active-datasets) 37 | - [Using a Custom Publish Function](#using-a-custom-publish-function) 38 | - [Example](#example-1) 39 | - [Tips](#tips) 40 | - [Get the DataTable instance](#get-the-datatable-instance) 41 | - [Detect row clicks and get row data](#detect-row-clicks-and-get-row-data) 42 | - [Search in one column](#search-in-one-column) 43 | - [Adjust column widths](#adjust-column-widths) 44 | - [Turning Off Paging or Showing "All"](#turning-off-paging-or-showing-all) 45 | - [Customize the "Processing" Message](#customize-the-processing-message) 46 | - [I18N Example](#i18n-example) 47 | - [Reactive Column Titles](#reactive-column-titles) 48 | - [Integrating DataTables Extensions](#integrating-datatables-extensions) 49 | - [Example: Adding Buttons](#example-adding-buttons) 50 | 51 | 52 | 53 | ## ATTENTION: Updating to 2.0 54 | 55 | Version 2.0 API is backwards compatible other than the following changes: 56 | - Requires Meteor 1.3+ 57 | - You must explicitly import the `Tabular` object into every file where you use it. (`import Tabular from 'meteor/aldeed:tabular';`) 58 | - You must configure the Bootstrap theme (or whatever theme you want) yourself. See [Installing and Configuring a Theme](#installing-and-configuring-a-theme) 59 | 60 | This version also includes a few fixes and a few new features. 61 | 62 | ## Features 63 | 64 | * Fast: Uses an intelligent automatic data subscription so that table data is not loaded until it's needed. 65 | * Reactive: As your collection data changes, so does your table. You can also reactively update the query selector if you provide your own filter buttons outside of the table. 66 | * Customizable: Anything you can do with the DataTables library is supported, and you can provide your own publish function to build custom tables or tables than join data from two collections. 67 | * Hot Code Push Ready: Remains on the same data page after a hot code push. 68 | 69 | Although this appears similar to the [jquery-datatables](https://github.com/LumaPictures/meteor-jquery-datatables) Meteor package, there are actually many differences: 70 | 71 | * This package is updated to work with Meteor 1.3+. 72 | * This package has a much smaller codebase and includes less of the DataTables library. 73 | * This package allows you to specify a Blaze template as a cell's content. 74 | * This package handles the reactive table updates in a different way. 75 | * This package is designed to work with any DataTables theme 76 | 77 | ## Installation 78 | 79 | ```bash 80 | $ meteor add aldeed:tabular 81 | ``` 82 | 83 | ## Installing and Configuring a Theme 84 | 85 | This example is for the Bootstrap theme. You can use another theme package. See https://datatables.net/download/npm 86 | 87 | First: 88 | 89 | ```bash 90 | $ npm install --save jquery@1.12.1 datatables.net-bs 91 | ``` 92 | 93 | Note that we install jquery@1.12.1. This needs to match the current version of jQuery included with Meteor's `jquery` package. (See the version comment in https://github.com/meteor/meteor/blob/master/packages/non-core/jquery/package.js) Otherwise, due to the `datatables.net` package depending on `jquery` NPM package, it might automatically install the latest `jquery` version, which may conflict with Bootstrap or Meteor. 94 | 95 | Then, somewhere in your client JavaScript: 96 | 97 | ```js 98 | import { $ } from 'meteor/jquery'; 99 | import dataTablesBootstrap from 'datatables.net-bs'; 100 | import 'datatables.net-bs/css/dataTables.bootstrap.css'; 101 | dataTablesBootstrap(window, $); 102 | ``` 103 | 104 | ## Online Demo App 105 | 106 | View a [demonstration project on Meteorpad](http://meteorpad.com/pad/xNafF9N5XJNrFJEyG/TabularDemo). 107 | 108 | Another example app courtesy of @AnnotatedJS: 109 | * Hosted app: http://greatalbums.meteor.com/albums (You can sign in with email "admin@demo.com" and password "password") 110 | * Source: https://github.com/AnnotatedJS/GreatAlbums 111 | 112 | ## Example 113 | 114 | Define your table in common code (code that runs in both NodeJS and browser): 115 | 116 | ```js 117 | import Tabular from 'meteor/aldeed:tabular'; 118 | import { Template } from 'meteor/templating'; 119 | import moment from 'moment'; 120 | import { Meteor } from 'meteor/meteor'; 121 | import { Books } from './collections/Books'; 122 | 123 | new Tabular.Table({ 124 | name: "Books", 125 | collection: Books, 126 | columns: [ 127 | {data: "title", title: "Title"}, 128 | {data: "author", title: "Author"}, 129 | {data: "copies", title: "Copies Available"}, 130 | { 131 | data: "lastCheckedOut", 132 | title: "Last Checkout", 133 | render: function (val, type, doc) { 134 | if (val instanceof Date) { 135 | return moment(val).calendar(); 136 | } else { 137 | return "Never"; 138 | } 139 | } 140 | }, 141 | {data: "summary", title: "Summary"}, 142 | { 143 | tmpl: Meteor.isClient && Template.bookCheckOutCell 144 | } 145 | ] 146 | }); 147 | ``` 148 | 149 | And then reference in one of your templates where you want it to appear: 150 | 151 | ```html 152 | {{> tabular table=TabularTables.Books class="table table-striped table-bordered table-condensed"}} 153 | ``` 154 | 155 | The `TabularTables.Books` helper is automatically added, where "Books" is the `name` option from your table constructor. 156 | 157 | ## Displaying Only Part of a Collection's Data Set 158 | 159 | Add a [Mongo-style selector](https://docs.meteor.com/#/full/selectors) to your `tabular` component for a table that displays only one part of a collection: 160 | 161 | ```html 162 | {{> tabular table=TabularTables.Books selector=selector class="table table-striped table-bordered table-condensed"}} 163 | ``` 164 | 165 | ```js 166 | Template.myTemplate.helpers({ 167 | selector() { 168 | return {author: "Agatha Christie"}; // this could be pulled from a Session var or something that is reactive 169 | } 170 | }); 171 | ``` 172 | 173 | If you want to limit what is published to the client for security reasons you can provide a selector in the constructor which will be used by the publications. Selectors provided this way will be combined with selectors provided to the template using an AND relationship. Both selectors may query on the same fields if necessary. 174 | 175 | ```js 176 | new Tabular.Table({ 177 | // other properties... 178 | selector(userId) { 179 | return { documentOwner: userId }; 180 | } 181 | }); 182 | ``` 183 | 184 | ## Passing Options to the DataTable 185 | 186 | The [DataTables documentation](http://datatables.net/reference/option/) lists a huge variety of available table options and callbacks. You may add any of these to your `Tabular.Table` constructor options and they will be used as options when constructing the DataTable. 187 | 188 | Example: 189 | 190 | ```js 191 | new Tabular.Table({ 192 | // other properties... 193 | createdRow( row, data, dataIndex ) { 194 | // set row class based on row data 195 | } 196 | }); 197 | ``` 198 | 199 | ## Template Cells 200 | 201 | You might have noticed this column definition in the example: 202 | 203 | ```js 204 | { 205 | tmpl: Meteor.isClient && Template.bookCheckOutCell 206 | } 207 | ``` 208 | 209 | This is not part of the DataTables API. It's a special feature of this package. By passing a Blaze Template object, that template will be rendered in the table cell. You can include a button and/or use helpers and events. 210 | 211 | In your template and helpers, `this` is set to the document for the current row by default. If you need more information in your template context, such as which column it is for a shared template, you can set `tmplContext` to a function which takes the row data as an argument and returns the context, like this: 212 | 213 | ```js 214 | { 215 | data: 'title', 216 | title: "Title", 217 | tmpl: Meteor.isClient && Template.sharedTemplate, 218 | tmplContext(rowData) { 219 | return { 220 | item: rowData, 221 | column: 'title' 222 | }; 223 | } 224 | } 225 | ``` 226 | 227 | *Note: The `Meteor.isClient && ` is there because tables must be defined in common code, which runs on the server and client. But the `Template` object is not defined in server code, so we need to prevent errors by setting `tmpl` only on the client.* 228 | 229 | The `tmpl` option can be used with or without the `data` option. 230 | 231 | Here's an example of how you might do the `bookCheckOutCell` template: 232 | 233 | HTML: 234 | 235 | ```html 236 | 239 | ``` 240 | 241 | Client JavaScript: 242 | 243 | ```js 244 | Template.bookCheckOutCell.events({ 245 | 'click .check-out': function () { 246 | addBookToCheckoutCart(this._id); 247 | } 248 | }); 249 | ``` 250 | 251 | ## Searching 252 | 253 | If your table includes the global search/filter field, it will work and will update results in a manner that remains fast even with large collections. By default, all columns are searched if they can be. If you don't want a column to be searched, add the `searchable: false` option on that column. 254 | 255 | When you enter multiple search terms separated by whitespace, they are searched with an OR condition, which matches default DataTables behavior. 256 | 257 | If your table has a `selector` that already limits the results, the search happens within the selector results (i.e., your selector and the search selector are merged with an AND relationship). 258 | 259 | ### Customizing Search Behavior 260 | 261 | You can add a `search` object to your table options to change the default behavior. The defaults are: 262 | 263 | ```js 264 | { 265 | search: { 266 | caseInsensitive: true, 267 | smart: true, 268 | onEnterOnly: false, 269 | } 270 | } 271 | ``` 272 | 273 | You can set `caseInsensitive` or `smart` to `false` if you prefer. See http://datatables.net/reference/option/search. The `regex` option is not yet supported. 274 | 275 | `onEnterOnly` is custom to this package. Set it to `true` to run search only when the user presses ENTER in the search box, rather than on keyup. This is useful for large collections to avoid slow searching. 276 | 277 | There are also two options to optimize searching for particular columns: 278 | 279 | ```js 280 | columns: [ 281 | { 282 | data: '_id', 283 | title: 'ID', 284 | search: { 285 | isNumber: true, 286 | exact: true, 287 | }, 288 | }, 289 | ] 290 | ``` 291 | 292 | For each column, you can set `search.isNumber` to `true` to cast whatever is entered to a `Number` and search for that, and you can set `search.exact` to `true` to search only for an exact match of the search string. (This overrides the table-level `caseInsensitive` and `smart` options for this column only.) 293 | 294 | ## Using Collection Helpers 295 | 296 | The DataTables library supports calling functions on the row data by appending your `data` string with `()`. This can be used along with the `dburles:collection-helpers` package (or your own collection transform). For example: 297 | 298 | *Relevant part of your table definition:* 299 | 300 | ```js 301 | columns: [ 302 | {data: "fullName()", title: "Full Name"}, 303 | ] 304 | ``` 305 | 306 | *A collection helper you've defined in client or common code:* 307 | 308 | ```js 309 | People.helpers({ 310 | fullName: function () { 311 | return this.firstName + ' ' + this.lastName; 312 | } 313 | }); 314 | ``` 315 | 316 | Note that for this to work properly, you must ensure that the `firstName` and `lastName` fields are published. If they're included as the `data` for other columns, then there is no problem. If not, you can use the `extraFields` option or your own custom publish function. 317 | 318 | ## Publishing Extra Fields 319 | 320 | If your table's templates or helper functions require fields that are not included in the data, you can tell Tabular to publish these fields by including them in the `extraFields` array option: 321 | 322 | ```js 323 | TabularTables.People = new Tabular.Table({ 324 | // other properties... 325 | extraFields: ['firstName', 'lastName'] 326 | }); 327 | ``` 328 | 329 | ## Modifying the Selector 330 | 331 | If your table requires the selector to be modified before it's published, you can modify it with the `changeSelector` method. This can be useful for modifying what will be returned in a search. It's called only on the server. 332 | 333 | ```js 334 | TabularTables.Posts = new Tabular.Table({ 335 | // other properties... 336 | changeSelector(selector, userId) { 337 | // modify it here ... 338 | return selector; 339 | } 340 | }); 341 | ``` 342 | 343 | ## Saving state 344 | 345 | Should you require the current state of pagination, sorting, search, etc to be saved you can use the default functionality of Datatables. 346 | 347 | Add stateSave as a property when defining the Datatable. 348 | ```js 349 | TabularTables.Posts = new Tabular.Table({ 350 | // other properties... 351 | stateSave: true 352 | }); 353 | ``` 354 | 355 | Add an ID parameter to the template include. This is used in localstorage by datatables to keep the state of your table. Without this state saving will not work. 356 | ```html 357 | {{> tabular table=TabularTables.Posts id="poststableid" selector=selector class="table table-striped table-bordered table-condensed"}} 358 | ``` 359 | 360 | ## Security 361 | 362 | You can optionally provide an `allow` and/or `allowFields` function to control which clients can get the published data. These are used by the built-in publications on the server only. 363 | 364 | ```js 365 | TabularTables.Books = new Tabular.Table({ 366 | // other properties... 367 | allow(userId) { 368 | return false; // don't allow this person to subscribe to the data 369 | }, 370 | allowFields(userId, fields) { 371 | return false; // don't allow this person to subscribe to the data 372 | } 373 | }); 374 | ``` 375 | 376 | *Note: Every time the table data changes, you can expect `allow` to be called 1 or 2 times and `allowFields` to be called 0 or 1 times. If the table uses your own custom publish function, then `allow` will be called 1 time and `allowFields` will never be called.* 377 | 378 | If you need to be sure that certain fields are never published or if different users can access different fields, use `allowFields`. Otherwise just use `allow`. 379 | 380 | ## Caching the Documents 381 | 382 | By default, a normal `Meteor.subscribe` is used for the current page's table data. This subscription is stopped and a new one replaces it whenever you switch pages. This means that if your table shows 10 results per page, your client collection will have 10 documents in it on page 1. When you switch to page 2, your client collection will still have only 10 documents in it, but they will be the next 10. 383 | 384 | If you want to override this behavior such that documents displayed in the table remain cached on the client for some time, you can add the `meteorhacks:subs-manager` package to your app and set the `sub` option on your `Tabular.Table`. This can make the table a bit faster and reduce unnecessary subscription traffic, but may not be a good idea if the data is extremely sensitive. 385 | 386 | ```js 387 | TabularTables.Books = new Tabular.Table({ 388 | // other properties... 389 | sub: new SubsManager() 390 | }); 391 | ``` 392 | 393 | ## Hooks 394 | 395 | Currently there is only one hook provided: `onUnload` 396 | 397 | ## Rendering a responsive table 398 | 399 | Use these table options: 400 | 401 | ```js 402 | responsive: true, 403 | autoWidth: false, 404 | ``` 405 | 406 | ## Active Datasets 407 | 408 | If your table is showing a dataset that changes a lot, it could become unusable due to reactively updating too often. You can throttle how often a table updates with the following table option: 409 | 410 | ```js 411 | throttleRefresh: 5000 412 | ``` 413 | 414 | Set it to the number of milliseconds to wait between updates, even if the data is changing more frequently. 415 | 416 | ## Using a Custom Publish Function 417 | 418 | This package takes care of publication and subscription for you using two built-in publications. The first publication determines the list of document `_id`s that 419 | are needed by the table. This is a complex publication and there should be no need to override it. The second publication publishes the actual documents with those `_id`s. 420 | 421 | The most common reason to override the second publication with your own custom one is to publish documents from related collections at the same time. 422 | 423 | To tell Tabular to use your custom publish function, pass the publication name as the `pub` option. Your function: 424 | 425 | * MUST accept and check three arguments: `tableName`, `ids`, and `fields` 426 | * MUST publish all the documents where `_id` is in the `ids` array. 427 | * MUST do any necessary security checks 428 | * SHOULD publish only the fields listed in the `fields` object, if one is provided. 429 | * MAY also publish other data necessary for your table 430 | 431 | ### Example 432 | 433 | Suppose we want a table of feedback submitted by users, which is stored in an `AppFeedback` collection, but we also want to display the email address of the user in the table. We'll use a custom publish function along with the [reywood:publish-composite](https://atmospherejs.com/reywood/publish-composite) package to do this. Also, we'll limit it to admins. 434 | 435 | *server/publish.js* 436 | 437 | ```js 438 | Meteor.publishComposite("tabular_AppFeedback", function (tableName, ids, fields) { 439 | check(tableName, String); 440 | check(ids, Array); 441 | check(fields, Match.Optional(Object)); 442 | 443 | this.unblock(); // requires meteorhacks:unblock package 444 | 445 | return { 446 | find: function () { 447 | this.unblock(); // requires meteorhacks:unblock package 448 | 449 | // check for admin role with alanning:roles package 450 | if (!Roles.userIsInRole(this.userId, 'admin')) { 451 | return []; 452 | } 453 | 454 | return AppFeedback.find({_id: {$in: ids}}, {fields: fields}); 455 | }, 456 | children: [ 457 | { 458 | find: function(feedback) { 459 | this.unblock(); // requires meteorhacks:unblock package 460 | // Publish the related user 461 | return Meteor.users.find({_id: feedback.userId}, {limit: 1, fields: {emails: 1}, sort: {_id: 1}}); 462 | } 463 | } 464 | ] 465 | }; 466 | }); 467 | ``` 468 | 469 | *common/helpers.js* 470 | 471 | ```js 472 | // Define an email helper on AppFeedback documents using dburles:collection-helpers package. 473 | // We'll reference this in our table columns with "email()" 474 | AppFeedback.helpers({ 475 | email() { 476 | var user = Meteor.users.findOne({_id: this.userId}); 477 | return user && user.emails[0].address; 478 | } 479 | }); 480 | ``` 481 | 482 | *common/tables.js* 483 | 484 | ```js 485 | TabularTables.AppFeedback = new Tabular.Table({ 486 | name: "AppFeedback", 487 | collection: AppFeedback, 488 | pub: "tabular_AppFeedback", 489 | allow(userId) { 490 | // check for admin role with alanning:roles package 491 | return Roles.userIsInRole(userId, 'admin'); 492 | }, 493 | order: [[0, "desc"]], 494 | columns: [ 495 | {data: "date", title: "Date"}, 496 | {data: "email()", title: "Email"}, 497 | {data: "feedback", title: "Feedback"}, 498 | { 499 | tmpl: Meteor.isClient && Template.appFeedbackCellDelete 500 | } 501 | ] 502 | }); 503 | ``` 504 | 505 | ## Tips 506 | 507 | Some useful tips 508 | 509 | ### Get the DataTable instance 510 | 511 | ```js 512 | var dt = $(theTableElement).DataTable(); 513 | ``` 514 | 515 | ### Detect row clicks and get row data 516 | 517 | ```js 518 | Template.myTemplate.events({ 519 | 'click tbody > tr': function (event) { 520 | var dataTable = $(event.target).closest('table').DataTable(); 521 | var rowData = dataTable.row(event.currentTarget).data(); 522 | if (!rowData) return; // Won't be data if a placeholder row is clicked 523 | // Your click handler logic here 524 | } 525 | }); 526 | ``` 527 | 528 | ### Search in one column 529 | 530 | ```js 531 | var dt = $(theTableElement).DataTable(); 532 | var indexOfColumnToSearch = 0; 533 | dt.column(indexOfColumnToSearch).search('search terms').draw(); 534 | ``` 535 | 536 | ### Adjust column widths 537 | 538 | By default, the DataTables library uses automatic column width calculations. If this makes some of your columns look squished, try setting the `autoWidth: false` option. 539 | 540 | ### Turning Off Paging or Showing "All" 541 | 542 | When using no paging or an "All" (-1) option in the page limit list, it is best to also add a hard limit in your table options like `limit: 500`, unless you know the collection will always be very small. 543 | 544 | ### Customize the "Processing" Message 545 | 546 | To customize the "Processing" message appearance, use CSS selector `div.dataTables_wrapper div.dataTables_processing`. To change or translate the text, see https://datatables.net/reference/option/language.processing 547 | 548 | ### I18N Example 549 | 550 | Before rendering the table on the client: 551 | 552 | 553 | ```js 554 | if (Meteor.isClient) { 555 | $.extend(true, $.fn.dataTable.defaults, { 556 | language: { 557 | "lengthMenu": i18n("tableDef.lengthMenu"), 558 | "zeroRecords": i18n("tableDef.zeroRecords"), 559 | "info": i18n("tableDef.info"), 560 | "infoEmpty": i18n("tableDef.infoEmpty"), 561 | "infoFiltered": i18n("tableDef.infoFiltered") 562 | } 563 | }); 564 | } 565 | ``` 566 | 567 | More options to translate can be found here: https://datatables.net/reference/option/language 568 | 569 | ### Reactive Column Titles 570 | 571 | You can set the `titleFn` column option to a function instead of supplying a string `title` option. This is reactively rerun as necessary. 572 | 573 | ### Optimizing the Total Table Count 574 | 575 | By default, a count of the entire available filtered dataset is done on the server. This can be slow for large datasets. You have two options that can help: 576 | 577 | First, you can calculate total counts yourself and return them from a function provided as the `alternativeCount` option to your `Tabular.Table`: 578 | 579 | ```js 580 | alternativeCount: (selector) => 200, 581 | ``` 582 | 583 | Second, you can skip the count altogether. If you do this, we return a fake count that ensures the Next button will be available. But the fake count will not be the correct total count, so the paging info and the numbered page buttons will be misleading. To deal with this, you should use `pagingType: 'simple'` and either `info: false` or an `infoCallback` function that omits the total count: 584 | 585 | ```js 586 | skipCount: true, 587 | pagingType: 'simple', 588 | infoCallback: (settings, start, end) => `Showing ${start} to ${end}`, 589 | ``` 590 | 591 | ## Integrating DataTables Extensions 592 | 593 | There are a wide variety of [useful extensions](http://datatables.net/extensions/index) for DataTables. To integrate them into Tabular, it is best to use the NPM packages. 594 | 595 | ### Example: Adding Buttons 596 | 597 | To add buttons for print, column visibility, file export, and more, you can use the DataTables buttons extension. Install the necessary packages in your app with NPM. For example, if you're using the Bootstrap theme, run: 598 | 599 | ```bash 600 | $ npm install --save datatables.net-buttons datatables.net-buttons-bs 601 | ``` 602 | 603 | For package names for other themes, see https://datatables.net/download/npm 604 | 605 | Once the packages are installed, you need to import them in one of your client JavaScript files: 606 | 607 | ```js 608 | import { $ } from 'meteor/jquery'; 609 | 610 | // Bootstrap Theme 611 | import dataTablesBootstrap from 'datatables.net-bs'; 612 | import 'datatables.net-bs/css/dataTables.bootstrap.css'; 613 | 614 | // Buttons Core 615 | import dataTableButtons from 'datatables.net-buttons-bs'; 616 | 617 | // Import whichever buttons you are using 618 | import columnVisibilityButton from 'datatables.net-buttons/js/buttons.colVis.js'; 619 | import html5ExportButtons from 'datatables.net-buttons/js/buttons.html5.js'; 620 | import flashExportButtons from 'datatables.net-buttons/js/buttons.flash.js'; 621 | import printButton from 'datatables.net-buttons/js/buttons.print.js'; 622 | 623 | // Then initialize everything you imported 624 | dataTablesBootstrap(window, $); 625 | dataTableButtons(window, $); 626 | columnVisibilityButton(window, $); 627 | html5ExportButtons(window, $); 628 | flashExportButtons(window, $); 629 | printButton(window, $); 630 | ``` 631 | 632 | Finally, for the Tabular tables that need them, add the `buttons` and `buttonContainer` options. The `buttons` option is part of DataTables and is documented here: https://datatables.net/extensions/buttons/ The `buttonContainer` option is part of `aldeed:tabular` and does the tricky task of appending the buttons to some element in the generated table. Set it to the CSS selector for the container. 633 | 634 | Bootstrap example: 635 | 636 | ```js 637 | new Tabular.Table({ 638 | // other properties... 639 | buttonContainer: '.col-sm-6:eq(0)', 640 | buttons: ['copy', 'excel', 'csv', 'colvis'], 641 | }); 642 | ``` 643 | 644 | If you are using the default DataTables theme, you can use the `dom` option instead of `buttonContainer`. See https://datatables.net/extensions/buttons/#Displaying-the-buttons 645 | --------------------------------------------------------------------------------