filter.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. // Filtered view manager
  2. // We define local filter objects for each different type of field (SetFilter,
  3. // RangeFilter, dateFilter, etc.). We then define a global `Filter` object whose
  4. // goal is to filter complete documents by using the local filters for each
  5. // fields.
  6. function showFilterSidebar() {
  7. Sidebar.setView('filter');
  8. }
  9. // Use a "set" filter for a field that is a set of documents uniquely
  10. // identified. For instance `{ labels: ['labelA', 'labelC', 'labelD'] }`.
  11. // use "subField" for searching inside object Fields.
  12. // For instance '{ 'customFields._id': ['field1','field2']} (subField would be: _id)
  13. class SetFilter {
  14. constructor(subField = '') {
  15. this._dep = new Tracker.Dependency();
  16. this._selectedElements = [];
  17. this.subField = subField;
  18. }
  19. isSelected(val) {
  20. this._dep.depend();
  21. return this._selectedElements.indexOf(val) > -1;
  22. }
  23. add(val) {
  24. if (this._indexOfVal(val) === -1) {
  25. this._selectedElements.push(val);
  26. this._dep.changed();
  27. showFilterSidebar();
  28. }
  29. }
  30. remove(val) {
  31. const indexOfVal = this._indexOfVal(val);
  32. if (this._indexOfVal(val) !== -1) {
  33. this._selectedElements.splice(indexOfVal, 1);
  34. this._dep.changed();
  35. }
  36. }
  37. toggle(val) {
  38. if (this._indexOfVal(val) === -1) {
  39. this.add(val);
  40. } else {
  41. this.remove(val);
  42. }
  43. }
  44. reset() {
  45. this._selectedElements = [];
  46. this._dep.changed();
  47. }
  48. _indexOfVal(val) {
  49. return this._selectedElements.indexOf(val);
  50. }
  51. _isActive() {
  52. this._dep.depend();
  53. return this._selectedElements.length !== 0;
  54. }
  55. _getMongoSelector() {
  56. this._dep.depend();
  57. return {
  58. $in: this._selectedElements,
  59. };
  60. }
  61. _getEmptySelector() {
  62. this._dep.depend();
  63. let includeEmpty = false;
  64. this._selectedElements.forEach(el => {
  65. if (el === undefined) {
  66. includeEmpty = true;
  67. }
  68. });
  69. return includeEmpty
  70. ? {
  71. $eq: [],
  72. }
  73. : null;
  74. }
  75. }
  76. // Advanced filter forms a MongoSelector from a users String.
  77. // Build by: Ignatz 19.05.2018 (github feuerball11)
  78. class AdvancedFilter {
  79. constructor() {
  80. this._dep = new Tracker.Dependency();
  81. this._filter = '';
  82. this._lastValide = {};
  83. }
  84. set(str) {
  85. this._filter = str;
  86. this._dep.changed();
  87. }
  88. reset() {
  89. this._filter = '';
  90. this._lastValide = {};
  91. this._dep.changed();
  92. }
  93. _isActive() {
  94. this._dep.depend();
  95. return this._filter !== '';
  96. }
  97. _filterToCommands() {
  98. const commands = [];
  99. let current = '';
  100. let string = false;
  101. let regex = false;
  102. let wasString = false;
  103. let ignore = false;
  104. for (let i = 0; i < this._filter.length; i++) {
  105. const char = this._filter.charAt(i);
  106. if (ignore) {
  107. ignore = false;
  108. current += char;
  109. continue;
  110. }
  111. if (char === '/') {
  112. string = !string;
  113. if (string) regex = true;
  114. current += char;
  115. continue;
  116. }
  117. // eslint-disable-next-line quotes
  118. if (char === "'") {
  119. string = !string;
  120. if (string) wasString = true;
  121. continue;
  122. }
  123. if (char === '\\' && !string) {
  124. ignore = true;
  125. continue;
  126. }
  127. if (char === ' ' && !string) {
  128. commands.push({
  129. cmd: current,
  130. string: wasString,
  131. regex,
  132. });
  133. wasString = false;
  134. current = '';
  135. continue;
  136. }
  137. current += char;
  138. }
  139. if (current !== '') {
  140. commands.push({
  141. cmd: current,
  142. string: wasString,
  143. regex,
  144. });
  145. }
  146. return commands;
  147. }
  148. _fieldNameToId(field) {
  149. const found = CustomFields.findOne({
  150. name: field,
  151. });
  152. return found._id;
  153. }
  154. _fieldValueToId(field, value) {
  155. const found = CustomFields.findOne({
  156. name: field,
  157. });
  158. if (
  159. found.settings.dropdownItems &&
  160. found.settings.dropdownItems.length > 0
  161. ) {
  162. for (let i = 0; i < found.settings.dropdownItems.length; i++) {
  163. if (found.settings.dropdownItems[i].name === value) {
  164. return found.settings.dropdownItems[i]._id;
  165. }
  166. }
  167. }
  168. return value;
  169. }
  170. _arrayToSelector(commands) {
  171. try {
  172. //let changed = false;
  173. this._processSubCommands(commands);
  174. } catch (e) {
  175. return this._lastValide;
  176. }
  177. this._lastValide = {
  178. $or: commands,
  179. };
  180. return {
  181. $or: commands,
  182. };
  183. }
  184. _processSubCommands(commands) {
  185. const subcommands = [];
  186. let level = 0;
  187. let start = -1;
  188. for (let i = 0; i < commands.length; i++) {
  189. if (commands[i].cmd) {
  190. switch (commands[i].cmd) {
  191. case '(': {
  192. level++;
  193. if (start === -1) start = i;
  194. continue;
  195. }
  196. case ')': {
  197. level--;
  198. commands.splice(i, 1);
  199. i--;
  200. continue;
  201. }
  202. default: {
  203. if (level > 0) {
  204. subcommands.push(commands[i]);
  205. commands.splice(i, 1);
  206. i--;
  207. continue;
  208. }
  209. }
  210. }
  211. }
  212. }
  213. if (start !== -1) {
  214. this._processSubCommands(subcommands);
  215. if (subcommands.length === 1) commands.splice(start, 0, subcommands[0]);
  216. else commands.splice(start, 0, subcommands);
  217. }
  218. this._processConditions(commands);
  219. this._processLogicalOperators(commands);
  220. }
  221. _processConditions(commands) {
  222. for (let i = 0; i < commands.length; i++) {
  223. if (!commands[i].string && commands[i].cmd) {
  224. switch (commands[i].cmd) {
  225. case '=':
  226. case '==':
  227. case '===': {
  228. const field = commands[i - 1].cmd;
  229. const str = commands[i + 1].cmd;
  230. if (commands[i + 1].regex) {
  231. const match = str.match(new RegExp('^/(.*?)/([gimy]*)$'));
  232. let regex = null;
  233. if (match.length > 2) regex = new RegExp(match[1], match[2]);
  234. else regex = new RegExp(match[1]);
  235. commands[i] = {
  236. 'customFields._id': this._fieldNameToId(field),
  237. 'customFields.value': regex,
  238. };
  239. } else {
  240. commands[i] = {
  241. 'customFields._id': this._fieldNameToId(field),
  242. 'customFields.value': {
  243. $in: [this._fieldValueToId(field, str), parseInt(str, 10)],
  244. },
  245. };
  246. }
  247. commands.splice(i - 1, 1);
  248. commands.splice(i, 1);
  249. //changed = true;
  250. i--;
  251. break;
  252. }
  253. case '!=':
  254. case '!==': {
  255. const field = commands[i - 1].cmd;
  256. const str = commands[i + 1].cmd;
  257. if (commands[i + 1].regex) {
  258. const match = str.match(new RegExp('^/(.*?)/([gimy]*)$'));
  259. let regex = null;
  260. if (match.length > 2) regex = new RegExp(match[1], match[2]);
  261. else regex = new RegExp(match[1]);
  262. commands[i] = {
  263. 'customFields._id': this._fieldNameToId(field),
  264. 'customFields.value': {
  265. $not: regex,
  266. },
  267. };
  268. } else {
  269. commands[i] = {
  270. 'customFields._id': this._fieldNameToId(field),
  271. 'customFields.value': {
  272. $not: {
  273. $in: [this._fieldValueToId(field, str), parseInt(str, 10)],
  274. },
  275. },
  276. };
  277. }
  278. commands.splice(i - 1, 1);
  279. commands.splice(i, 1);
  280. //changed = true;
  281. i--;
  282. break;
  283. }
  284. case '>':
  285. case 'gt':
  286. case 'Gt':
  287. case 'GT': {
  288. const field = commands[i - 1].cmd;
  289. const str = commands[i + 1].cmd;
  290. commands[i] = {
  291. 'customFields._id': this._fieldNameToId(field),
  292. 'customFields.value': {
  293. $gt: parseInt(str, 10),
  294. },
  295. };
  296. commands.splice(i - 1, 1);
  297. commands.splice(i, 1);
  298. //changed = true;
  299. i--;
  300. break;
  301. }
  302. case '>=':
  303. case '>==':
  304. case 'gte':
  305. case 'Gte':
  306. case 'GTE': {
  307. const field = commands[i - 1].cmd;
  308. const str = commands[i + 1].cmd;
  309. commands[i] = {
  310. 'customFields._id': this._fieldNameToId(field),
  311. 'customFields.value': {
  312. $gte: parseInt(str, 10),
  313. },
  314. };
  315. commands.splice(i - 1, 1);
  316. commands.splice(i, 1);
  317. //changed = true;
  318. i--;
  319. break;
  320. }
  321. case '<':
  322. case 'lt':
  323. case 'Lt':
  324. case 'LT': {
  325. const field = commands[i - 1].cmd;
  326. const str = commands[i + 1].cmd;
  327. commands[i] = {
  328. 'customFields._id': this._fieldNameToId(field),
  329. 'customFields.value': {
  330. $lt: parseInt(str, 10),
  331. },
  332. };
  333. commands.splice(i - 1, 1);
  334. commands.splice(i, 1);
  335. //changed = true;
  336. i--;
  337. break;
  338. }
  339. case '<=':
  340. case '<==':
  341. case 'lte':
  342. case 'Lte':
  343. case 'LTE': {
  344. const field = commands[i - 1].cmd;
  345. const str = commands[i + 1].cmd;
  346. commands[i] = {
  347. 'customFields._id': this._fieldNameToId(field),
  348. 'customFields.value': {
  349. $lte: parseInt(str, 10),
  350. },
  351. };
  352. commands.splice(i - 1, 1);
  353. commands.splice(i, 1);
  354. //changed = true;
  355. i--;
  356. break;
  357. }
  358. }
  359. }
  360. }
  361. }
  362. _processLogicalOperators(commands) {
  363. for (let i = 0; i < commands.length; i++) {
  364. if (!commands[i].string && commands[i].cmd) {
  365. switch (commands[i].cmd) {
  366. case 'or':
  367. case 'Or':
  368. case 'OR':
  369. case '|':
  370. case '||': {
  371. const op1 = commands[i - 1];
  372. const op2 = commands[i + 1];
  373. commands[i] = {
  374. $or: [op1, op2],
  375. };
  376. commands.splice(i - 1, 1);
  377. commands.splice(i, 1);
  378. //changed = true;
  379. i--;
  380. break;
  381. }
  382. case 'and':
  383. case 'And':
  384. case 'AND':
  385. case '&':
  386. case '&&': {
  387. const op1 = commands[i - 1];
  388. const op2 = commands[i + 1];
  389. commands[i] = {
  390. $and: [op1, op2],
  391. };
  392. commands.splice(i - 1, 1);
  393. commands.splice(i, 1);
  394. //changed = true;
  395. i--;
  396. break;
  397. }
  398. case 'not':
  399. case 'Not':
  400. case 'NOT':
  401. case '!': {
  402. const op1 = commands[i + 1];
  403. commands[i] = {
  404. $not: op1,
  405. };
  406. commands.splice(i + 1, 1);
  407. //changed = true;
  408. i--;
  409. break;
  410. }
  411. }
  412. }
  413. }
  414. }
  415. _getMongoSelector() {
  416. this._dep.depend();
  417. const commands = this._filterToCommands();
  418. return this._arrayToSelector(commands);
  419. }
  420. }
  421. // The global Filter object.
  422. // XXX It would be possible to re-write this object more elegantly, and removing
  423. // the need to provide a list of `_fields`. We also should move methods into the
  424. // object prototype.
  425. Filter = {
  426. // XXX I would like to rename this field into `labels` to be consistent with
  427. // the rest of the schema, but we need to set some migrations architecture
  428. // before changing the schema.
  429. labelIds: new SetFilter(),
  430. members: new SetFilter(),
  431. customFields: new SetFilter('_id'),
  432. advanced: new AdvancedFilter(),
  433. _fields: ['labelIds', 'members', 'customFields'],
  434. // We don't filter cards that have been added after the last filter change. To
  435. // implement this we keep the id of these cards in this `_exceptions` fields
  436. // and use a `$or` condition in the mongo selector we return.
  437. _exceptions: [],
  438. _exceptionsDep: new Tracker.Dependency(),
  439. isActive() {
  440. return (
  441. _.any(this._fields, fieldName => {
  442. return this[fieldName]._isActive();
  443. }) || this.advanced._isActive()
  444. );
  445. },
  446. _getMongoSelector() {
  447. if (!this.isActive()) return {};
  448. const filterSelector = {};
  449. const emptySelector = {};
  450. let includeEmptySelectors = false;
  451. this._fields.forEach(fieldName => {
  452. const filter = this[fieldName];
  453. if (filter._isActive()) {
  454. if (filter.subField !== '') {
  455. filterSelector[
  456. `${fieldName}.${filter.subField}`
  457. ] = filter._getMongoSelector();
  458. } else {
  459. filterSelector[fieldName] = filter._getMongoSelector();
  460. }
  461. emptySelector[fieldName] = filter._getEmptySelector();
  462. if (emptySelector[fieldName] !== null) {
  463. includeEmptySelectors = true;
  464. }
  465. }
  466. });
  467. const exceptionsSelector = {
  468. _id: {
  469. $in: this._exceptions,
  470. },
  471. };
  472. this._exceptionsDep.depend();
  473. const selectors = [exceptionsSelector];
  474. if (
  475. _.any(this._fields, fieldName => {
  476. return this[fieldName]._isActive();
  477. })
  478. )
  479. selectors.push(filterSelector);
  480. if (includeEmptySelectors) selectors.push(emptySelector);
  481. if (this.advanced._isActive())
  482. selectors.push(this.advanced._getMongoSelector());
  483. return {
  484. $or: selectors,
  485. };
  486. },
  487. mongoSelector(additionalSelector) {
  488. const filterSelector = this._getMongoSelector();
  489. if (_.isUndefined(additionalSelector)) return filterSelector;
  490. else
  491. return {
  492. $and: [filterSelector, additionalSelector],
  493. };
  494. },
  495. reset() {
  496. this._fields.forEach(fieldName => {
  497. const filter = this[fieldName];
  498. filter.reset();
  499. });
  500. this.advanced.reset();
  501. this.resetExceptions();
  502. },
  503. addException(_id) {
  504. if (this.isActive()) {
  505. this._exceptions.push(_id);
  506. this._exceptionsDep.changed();
  507. Tracker.flush();
  508. }
  509. },
  510. resetExceptions() {
  511. this._exceptions = [];
  512. this._exceptionsDep.changed();
  513. },
  514. };
  515. Blaze.registerHelper('Filter', Filter);