filter.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  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. class DateFilter {
  10. constructor() {
  11. this._dep = new Tracker.Dependency();
  12. this.subField = ''; // Prevent name mangling in Filter
  13. this._filter = null;
  14. this._filterState = null;
  15. }
  16. _updateState(state) {
  17. this._filterState = state;
  18. showFilterSidebar();
  19. this._dep.changed();
  20. }
  21. // past builds a filter for all dates before now
  22. past() {
  23. if (this._filterState == 'past') { this.reset(); return; }
  24. this._filter = { $lte: moment().toDate() };
  25. this._updateState('past');
  26. }
  27. // today is a convenience method for calling relativeDay with 0
  28. today() {
  29. if (this._filterState == 'today') { this.reset(); return; }
  30. this.relativeDay(0);
  31. this._updateState('today');
  32. }
  33. // tomorrow is a convenience method for calling relativeDay with 1
  34. tomorrow() {
  35. if (this._filterState == 'tomorrow') { this.reset(); return; }
  36. this.relativeDay(1);
  37. this._updateState('tomorrow');
  38. }
  39. // thisWeek is a convenience method for calling relativeWeek with 1
  40. thisWeek() {
  41. this.relativeWeek(1);
  42. }
  43. // relativeDay builds a filter starting from now and including all
  44. // days up to today +/- offset.
  45. relativeDay(offset) {
  46. if (this._filterState == 'day') { this.reset(); return; }
  47. var startDay = moment().startOf('day').toDate(),
  48. endDay = moment().endOf('day').add(offset, 'day').toDate();
  49. if (offset >= 0) {
  50. this._filter = { $gte: startDay, $lte: endDay };
  51. } else {
  52. this._filter = { $lte: startDay, $gte: endDay };
  53. }
  54. this._updateState('day');
  55. }
  56. // relativeWeek builds a filter starting from today and including all
  57. // weeks up to today +/- offset. This considers the user's preferred
  58. // start of week day (as defined by Meteor).
  59. relativeWeek(offset) {
  60. if (this._filterState == 'week') { this.reset(); return; }
  61. // getStartDayOfWeek returns the offset from Sunday of the user's
  62. // preferred starting day of the week. This date should be added
  63. // to the moment start of week to get the real start of week date.
  64. // The default is 1, meaning Monday.
  65. const currentUser = Meteor.user();
  66. const weekStartDay = currentUser ? currentUser.getStartDayOfWeek() : 1;
  67. // Moments are mutable so they must be cloned before modification
  68. var thisWeekStart = moment().startOf('day').startOf('week').add(weekStartDay, 'days');
  69. var thisWeekEnd = thisWeekStart.clone().add(offset, 'week').endOf('day');
  70. var startDate = thisWeekStart.toDate();
  71. var endDate = thisWeekEnd.toDate();
  72. if (offset >= 0) {
  73. this._filter = { $gte: startDate, $lte: endDate };
  74. } else {
  75. this._filter = { $lte: startDate, $gte: endDate };
  76. }
  77. this._updateState('week');
  78. }
  79. // noDate builds a filter for items where date is not set
  80. noDate() {
  81. if (this._filterState == 'noDate') { this.reset(); return; }
  82. this._filter = null;
  83. this._updateState('noDate');
  84. }
  85. reset() {
  86. this._filter = null;
  87. this._filterState = null;
  88. this._dep.changed();
  89. }
  90. isSelected(val) {
  91. this._dep.depend();
  92. return this._filterState == val;
  93. }
  94. _isActive() {
  95. this._dep.depend();
  96. return this._filterState !== null;
  97. }
  98. _getMongoSelector() {
  99. this._dep.depend();
  100. return this._filter;
  101. }
  102. _getEmptySelector() {
  103. this._dep.depend();
  104. return null;
  105. }
  106. }
  107. // Use a "set" filter for a field that is a set of documents uniquely
  108. // identified. For instance `{ labels: ['labelA', 'labelC', 'labelD'] }`.
  109. // use "subField" for searching inside object Fields.
  110. // For instance '{ 'customFields._id': ['field1','field2']} (subField would be: _id)
  111. class SetFilter {
  112. constructor(subField = '') {
  113. this._dep = new Tracker.Dependency();
  114. this._selectedElements = [];
  115. this.subField = subField;
  116. }
  117. isSelected(val) {
  118. this._dep.depend();
  119. return this._selectedElements.indexOf(val) > -1;
  120. }
  121. add(val) {
  122. if (this._indexOfVal(val) === -1) {
  123. this._selectedElements.push(val);
  124. this._dep.changed();
  125. showFilterSidebar();
  126. }
  127. }
  128. remove(val) {
  129. const indexOfVal = this._indexOfVal(val);
  130. if (this._indexOfVal(val) !== -1) {
  131. this._selectedElements.splice(indexOfVal, 1);
  132. this._dep.changed();
  133. }
  134. }
  135. toggle(val) {
  136. if (this._indexOfVal(val) === -1) {
  137. this.add(val);
  138. } else {
  139. this.remove(val);
  140. }
  141. }
  142. reset() {
  143. this._selectedElements = [];
  144. this._dep.changed();
  145. }
  146. _indexOfVal(val) {
  147. return this._selectedElements.indexOf(val);
  148. }
  149. _isActive() {
  150. this._dep.depend();
  151. return this._selectedElements.length !== 0;
  152. }
  153. _getMongoSelector() {
  154. this._dep.depend();
  155. return {
  156. $in: this._selectedElements,
  157. };
  158. }
  159. _getEmptySelector() {
  160. this._dep.depend();
  161. let includeEmpty = false;
  162. this._selectedElements.forEach(el => {
  163. if (el === undefined) {
  164. includeEmpty = true;
  165. }
  166. });
  167. return includeEmpty
  168. ? {
  169. $eq: [],
  170. }
  171. : null;
  172. }
  173. }
  174. // Advanced filter forms a MongoSelector from a users String.
  175. // Build by: Ignatz 19.05.2018 (github feuerball11)
  176. class AdvancedFilter {
  177. constructor() {
  178. this._dep = new Tracker.Dependency();
  179. this._filter = '';
  180. this._lastValide = {};
  181. }
  182. set(str) {
  183. this._filter = str;
  184. this._dep.changed();
  185. }
  186. reset() {
  187. this._filter = '';
  188. this._lastValide = {};
  189. this._dep.changed();
  190. }
  191. _isActive() {
  192. this._dep.depend();
  193. return this._filter !== '';
  194. }
  195. _filterToCommands() {
  196. const commands = [];
  197. let current = '';
  198. let string = false;
  199. let regex = false;
  200. let wasString = false;
  201. let ignore = false;
  202. for (let i = 0; i < this._filter.length; i++) {
  203. const char = this._filter.charAt(i);
  204. if (ignore) {
  205. ignore = false;
  206. current += char;
  207. continue;
  208. }
  209. if (char === '/') {
  210. string = !string;
  211. if (string) regex = true;
  212. current += char;
  213. continue;
  214. }
  215. // eslint-disable-next-line quotes
  216. if (char === "'") {
  217. string = !string;
  218. if (string) wasString = true;
  219. continue;
  220. }
  221. if (char === '\\' && !string) {
  222. ignore = true;
  223. continue;
  224. }
  225. if (char === ' ' && !string) {
  226. commands.push({
  227. cmd: current,
  228. string: wasString,
  229. regex,
  230. });
  231. wasString = false;
  232. current = '';
  233. continue;
  234. }
  235. current += char;
  236. }
  237. if (current !== '') {
  238. commands.push({
  239. cmd: current,
  240. string: wasString,
  241. regex,
  242. });
  243. }
  244. return commands;
  245. }
  246. _fieldNameToId(field) {
  247. const found = CustomFields.findOne({
  248. name: field,
  249. });
  250. return found._id;
  251. }
  252. _fieldValueToId(field, value) {
  253. const found = CustomFields.findOne({
  254. name: field,
  255. });
  256. if (
  257. found.settings.dropdownItems &&
  258. found.settings.dropdownItems.length > 0
  259. ) {
  260. for (let i = 0; i < found.settings.dropdownItems.length; i++) {
  261. if (found.settings.dropdownItems[i].name === value) {
  262. return found.settings.dropdownItems[i]._id;
  263. }
  264. }
  265. }
  266. return value;
  267. }
  268. _arrayToSelector(commands) {
  269. try {
  270. //let changed = false;
  271. this._processSubCommands(commands);
  272. } catch (e) {
  273. return this._lastValide;
  274. }
  275. this._lastValide = {
  276. $or: commands,
  277. };
  278. return {
  279. $or: commands,
  280. };
  281. }
  282. _processSubCommands(commands) {
  283. const subcommands = [];
  284. let level = 0;
  285. let start = -1;
  286. for (let i = 0; i < commands.length; i++) {
  287. if (commands[i].cmd) {
  288. switch (commands[i].cmd) {
  289. case '(': {
  290. level++;
  291. if (start === -1) start = i;
  292. continue;
  293. }
  294. case ')': {
  295. level--;
  296. commands.splice(i, 1);
  297. i--;
  298. continue;
  299. }
  300. default: {
  301. if (level > 0) {
  302. subcommands.push(commands[i]);
  303. commands.splice(i, 1);
  304. i--;
  305. continue;
  306. }
  307. }
  308. }
  309. }
  310. }
  311. if (start !== -1) {
  312. this._processSubCommands(subcommands);
  313. if (subcommands.length === 1) commands.splice(start, 0, subcommands[0]);
  314. else commands.splice(start, 0, subcommands);
  315. }
  316. this._processConditions(commands);
  317. this._processLogicalOperators(commands);
  318. }
  319. _processConditions(commands) {
  320. for (let i = 0; i < commands.length; i++) {
  321. if (!commands[i].string && commands[i].cmd) {
  322. switch (commands[i].cmd) {
  323. case '=':
  324. case '==':
  325. case '===': {
  326. const field = commands[i - 1].cmd;
  327. const str = commands[i + 1].cmd;
  328. if (commands[i + 1].regex) {
  329. const match = str.match(new RegExp('^/(.*?)/([gimy]*)$'));
  330. let regex = null;
  331. if (match.length > 2) regex = new RegExp(match[1], match[2]);
  332. else regex = new RegExp(match[1]);
  333. commands[i] = {
  334. 'customFields._id': this._fieldNameToId(field),
  335. 'customFields.value': regex,
  336. };
  337. } else {
  338. commands[i] = {
  339. 'customFields._id': this._fieldNameToId(field),
  340. 'customFields.value': {
  341. $in: [this._fieldValueToId(field, str), parseInt(str, 10)],
  342. },
  343. };
  344. }
  345. commands.splice(i - 1, 1);
  346. commands.splice(i, 1);
  347. //changed = true;
  348. i--;
  349. break;
  350. }
  351. case '!=':
  352. case '!==': {
  353. const field = commands[i - 1].cmd;
  354. const str = commands[i + 1].cmd;
  355. if (commands[i + 1].regex) {
  356. const match = str.match(new RegExp('^/(.*?)/([gimy]*)$'));
  357. let regex = null;
  358. if (match.length > 2) regex = new RegExp(match[1], match[2]);
  359. else regex = new RegExp(match[1]);
  360. commands[i] = {
  361. 'customFields._id': this._fieldNameToId(field),
  362. 'customFields.value': {
  363. $not: regex,
  364. },
  365. };
  366. } else {
  367. commands[i] = {
  368. 'customFields._id': this._fieldNameToId(field),
  369. 'customFields.value': {
  370. $not: {
  371. $in: [this._fieldValueToId(field, str), parseInt(str, 10)],
  372. },
  373. },
  374. };
  375. }
  376. commands.splice(i - 1, 1);
  377. commands.splice(i, 1);
  378. //changed = true;
  379. i--;
  380. break;
  381. }
  382. case '>':
  383. case 'gt':
  384. case 'Gt':
  385. case 'GT': {
  386. const field = commands[i - 1].cmd;
  387. const str = commands[i + 1].cmd;
  388. commands[i] = {
  389. 'customFields._id': this._fieldNameToId(field),
  390. 'customFields.value': {
  391. $gt: parseInt(str, 10),
  392. },
  393. };
  394. commands.splice(i - 1, 1);
  395. commands.splice(i, 1);
  396. //changed = true;
  397. i--;
  398. break;
  399. }
  400. case '>=':
  401. case '>==':
  402. case 'gte':
  403. case 'Gte':
  404. case 'GTE': {
  405. const field = commands[i - 1].cmd;
  406. const str = commands[i + 1].cmd;
  407. commands[i] = {
  408. 'customFields._id': this._fieldNameToId(field),
  409. 'customFields.value': {
  410. $gte: parseInt(str, 10),
  411. },
  412. };
  413. commands.splice(i - 1, 1);
  414. commands.splice(i, 1);
  415. //changed = true;
  416. i--;
  417. break;
  418. }
  419. case '<':
  420. case 'lt':
  421. case 'Lt':
  422. case 'LT': {
  423. const field = commands[i - 1].cmd;
  424. const str = commands[i + 1].cmd;
  425. commands[i] = {
  426. 'customFields._id': this._fieldNameToId(field),
  427. 'customFields.value': {
  428. $lt: parseInt(str, 10),
  429. },
  430. };
  431. commands.splice(i - 1, 1);
  432. commands.splice(i, 1);
  433. //changed = true;
  434. i--;
  435. break;
  436. }
  437. case '<=':
  438. case '<==':
  439. case 'lte':
  440. case 'Lte':
  441. case 'LTE': {
  442. const field = commands[i - 1].cmd;
  443. const str = commands[i + 1].cmd;
  444. commands[i] = {
  445. 'customFields._id': this._fieldNameToId(field),
  446. 'customFields.value': {
  447. $lte: parseInt(str, 10),
  448. },
  449. };
  450. commands.splice(i - 1, 1);
  451. commands.splice(i, 1);
  452. //changed = true;
  453. i--;
  454. break;
  455. }
  456. }
  457. }
  458. }
  459. }
  460. _processLogicalOperators(commands) {
  461. for (let i = 0; i < commands.length; i++) {
  462. if (!commands[i].string && commands[i].cmd) {
  463. switch (commands[i].cmd) {
  464. case 'or':
  465. case 'Or':
  466. case 'OR':
  467. case '|':
  468. case '||': {
  469. const op1 = commands[i - 1];
  470. const op2 = commands[i + 1];
  471. commands[i] = {
  472. $or: [op1, op2],
  473. };
  474. commands.splice(i - 1, 1);
  475. commands.splice(i, 1);
  476. //changed = true;
  477. i--;
  478. break;
  479. }
  480. case 'and':
  481. case 'And':
  482. case 'AND':
  483. case '&':
  484. case '&&': {
  485. const op1 = commands[i - 1];
  486. const op2 = commands[i + 1];
  487. commands[i] = {
  488. $and: [op1, op2],
  489. };
  490. commands.splice(i - 1, 1);
  491. commands.splice(i, 1);
  492. //changed = true;
  493. i--;
  494. break;
  495. }
  496. case 'not':
  497. case 'Not':
  498. case 'NOT':
  499. case '!': {
  500. const op1 = commands[i + 1];
  501. commands[i] = {
  502. $not: op1,
  503. };
  504. commands.splice(i + 1, 1);
  505. //changed = true;
  506. i--;
  507. break;
  508. }
  509. }
  510. }
  511. }
  512. }
  513. _getMongoSelector() {
  514. this._dep.depend();
  515. const commands = this._filterToCommands();
  516. return this._arrayToSelector(commands);
  517. }
  518. getRegexSelector() {
  519. // generate a regex for filter list
  520. this._dep.depend();
  521. return new RegExp(
  522. `^.*${this._filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`,
  523. 'i',
  524. );
  525. }
  526. }
  527. // The global Filter object.
  528. // XXX It would be possible to re-write this object more elegantly, and removing
  529. // the need to provide a list of `_fields`. We also should move methods into the
  530. // object prototype.
  531. Filter = {
  532. // XXX I would like to rename this field into `labels` to be consistent with
  533. // the rest of the schema, but we need to set some migrations architecture
  534. // before changing the schema.
  535. labelIds: new SetFilter(),
  536. members: new SetFilter(),
  537. assignees: new SetFilter(),
  538. archive: new SetFilter(),
  539. hideEmpty: new SetFilter(),
  540. dueAt: new DateFilter(),
  541. customFields: new SetFilter('_id'),
  542. advanced: new AdvancedFilter(),
  543. lists: new AdvancedFilter(), // we need the ability to filter list by name as well
  544. _fields: [
  545. 'labelIds',
  546. 'members',
  547. 'assignees',
  548. 'archive',
  549. 'hideEmpty',
  550. 'dueAt',
  551. 'customFields',
  552. ],
  553. // We don't filter cards that have been added after the last filter change. To
  554. // implement this we keep the id of these cards in this `_exceptions` fields
  555. // and use a `$or` condition in the mongo selector we return.
  556. _exceptions: [],
  557. _exceptionsDep: new Tracker.Dependency(),
  558. isActive() {
  559. return (
  560. _.any(this._fields, fieldName => {
  561. return this[fieldName]._isActive();
  562. }) ||
  563. this.advanced._isActive() ||
  564. this.lists._isActive()
  565. );
  566. },
  567. _getMongoSelector() {
  568. if (!this.isActive()) return {};
  569. const filterSelector = {};
  570. const emptySelector = {};
  571. let includeEmptySelectors = false;
  572. this._fields.forEach(fieldName => {
  573. const filter = this[fieldName];
  574. if (filter._isActive()) {
  575. if (filter.subField !== '') {
  576. filterSelector[
  577. `${fieldName}.${filter.subField}`
  578. ] = filter._getMongoSelector();
  579. } else {
  580. filterSelector[fieldName] = filter._getMongoSelector();
  581. }
  582. emptySelector[fieldName] = filter._getEmptySelector();
  583. if (emptySelector[fieldName] !== null) {
  584. includeEmptySelectors = true;
  585. }
  586. }
  587. });
  588. const exceptionsSelector = {
  589. _id: {
  590. $in: this._exceptions,
  591. },
  592. };
  593. this._exceptionsDep.depend();
  594. const selectors = [exceptionsSelector];
  595. if (
  596. _.any(this._fields, fieldName => {
  597. return this[fieldName]._isActive();
  598. })
  599. )
  600. selectors.push(filterSelector);
  601. if (includeEmptySelectors) selectors.push(emptySelector);
  602. if (this.advanced._isActive())
  603. selectors.push(this.advanced._getMongoSelector());
  604. return {
  605. $or: selectors,
  606. };
  607. },
  608. mongoSelector(additionalSelector) {
  609. const filterSelector = this._getMongoSelector();
  610. if (_.isUndefined(additionalSelector)) return filterSelector;
  611. else
  612. return {
  613. $and: [filterSelector, additionalSelector],
  614. };
  615. },
  616. reset() {
  617. this._fields.forEach(fieldName => {
  618. const filter = this[fieldName];
  619. filter.reset();
  620. });
  621. this.lists.reset();
  622. this.advanced.reset();
  623. this.resetExceptions();
  624. },
  625. addException(_id) {
  626. if (this.isActive()) {
  627. this._exceptions.push(_id);
  628. this._exceptionsDep.changed();
  629. Tracker.flush();
  630. }
  631. },
  632. resetExceptions() {
  633. this._exceptions = [];
  634. this._exceptionsDep.changed();
  635. },
  636. };
  637. Blaze.registerHelper('Filter', Filter);