3 | {{! somehow the div wrapper helps the table get cleaned up properly for example when switching between routes}}
4 |
5 |
6 |
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 |
237 |
238 |
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 |
--------------------------------------------------------------------------------