filter.js 20 KB

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