filter.js 18 KB

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