filter.js 19 KB

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