filter.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  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. class StringFilter {
  137. constructor() {
  138. this._dep = new Tracker.Dependency();
  139. this.subField = ''; // Prevent name mangling in Filter
  140. this._filter = '';
  141. }
  142. set(str) {
  143. this._filter = str;
  144. this._dep.changed();
  145. }
  146. reset() {
  147. this._filter = '';
  148. this._dep.changed();
  149. }
  150. _isActive() {
  151. this._dep.depend();
  152. return this._filter !== '';
  153. }
  154. _getMongoSelector() {
  155. this._dep.depend();
  156. return {$regex : this._filter, $options: 'i'};
  157. }
  158. _getEmptySelector() {
  159. this._dep.depend();
  160. return {$regex : this._filter, $options: 'i'};
  161. }
  162. }
  163. // Use a "set" filter for a field that is a set of documents uniquely
  164. // identified. For instance `{ labels: ['labelA', 'labelC', 'labelD'] }`.
  165. // use "subField" for searching inside object Fields.
  166. // For instance '{ 'customFields._id': ['field1','field2']} (subField would be: _id)
  167. class SetFilter {
  168. constructor(subField = '') {
  169. this._dep = new Tracker.Dependency();
  170. this._selectedElements = [];
  171. this.subField = subField;
  172. }
  173. isSelected(val) {
  174. this._dep.depend();
  175. return this._selectedElements.indexOf(val) > -1;
  176. }
  177. add(val) {
  178. if (this._indexOfVal(val) === -1) {
  179. this._selectedElements.push(val);
  180. this._dep.changed();
  181. showFilterSidebar();
  182. }
  183. }
  184. remove(val) {
  185. const indexOfVal = this._indexOfVal(val);
  186. if (this._indexOfVal(val) !== -1) {
  187. this._selectedElements.splice(indexOfVal, 1);
  188. this._dep.changed();
  189. }
  190. }
  191. toggle(val) {
  192. if (this._indexOfVal(val) === -1) {
  193. this.add(val);
  194. } else {
  195. this.remove(val);
  196. }
  197. }
  198. reset() {
  199. this._selectedElements = [];
  200. this._dep.changed();
  201. }
  202. _indexOfVal(val) {
  203. return this._selectedElements.indexOf(val);
  204. }
  205. _isActive() {
  206. this._dep.depend();
  207. return this._selectedElements.length !== 0;
  208. }
  209. _getMongoSelector() {
  210. this._dep.depend();
  211. return {
  212. $in: this._selectedElements,
  213. };
  214. }
  215. _getEmptySelector() {
  216. this._dep.depend();
  217. let includeEmpty = false;
  218. this._selectedElements.forEach(el => {
  219. if (el === undefined) {
  220. includeEmpty = true;
  221. }
  222. });
  223. return includeEmpty
  224. ? {
  225. $eq: [],
  226. }
  227. : null;
  228. }
  229. }
  230. // Advanced filter forms a MongoSelector from a users String.
  231. // Build by: Ignatz 19.05.2018 (github feuerball11)
  232. class AdvancedFilter {
  233. constructor() {
  234. this._dep = new Tracker.Dependency();
  235. this._filter = '';
  236. this._lastValide = {};
  237. }
  238. set(str) {
  239. this._filter = str;
  240. this._dep.changed();
  241. }
  242. reset() {
  243. this._filter = '';
  244. this._lastValide = {};
  245. this._dep.changed();
  246. }
  247. _isActive() {
  248. this._dep.depend();
  249. return this._filter !== '';
  250. }
  251. _filterToCommands() {
  252. const commands = [];
  253. let current = '';
  254. let string = false;
  255. let regex = false;
  256. let wasString = false;
  257. let ignore = false;
  258. for (let i = 0; i < this._filter.length; i++) {
  259. const char = this._filter.charAt(i);
  260. if (ignore) {
  261. ignore = false;
  262. current += char;
  263. continue;
  264. }
  265. if (char === '/') {
  266. string = !string;
  267. if (string) regex = true;
  268. current += char;
  269. continue;
  270. }
  271. // eslint-disable-next-line quotes
  272. if (char === "'") {
  273. string = !string;
  274. if (string) wasString = true;
  275. continue;
  276. }
  277. if (char === '\\' && !string) {
  278. ignore = true;
  279. continue;
  280. }
  281. if (char === ' ' && !string) {
  282. commands.push({
  283. cmd: current,
  284. string: wasString,
  285. regex,
  286. });
  287. wasString = false;
  288. current = '';
  289. continue;
  290. }
  291. current += char;
  292. }
  293. if (current !== '') {
  294. commands.push({
  295. cmd: current,
  296. string: wasString,
  297. regex,
  298. });
  299. }
  300. return commands;
  301. }
  302. _fieldNameToId(field) {
  303. const found = CustomFields.findOne({
  304. name: field,
  305. });
  306. return found._id;
  307. }
  308. _fieldValueToId(field, value) {
  309. const found = CustomFields.findOne({
  310. name: field,
  311. });
  312. if (
  313. found.settings.dropdownItems &&
  314. found.settings.dropdownItems.length > 0
  315. ) {
  316. for (let i = 0; i < found.settings.dropdownItems.length; i++) {
  317. if (found.settings.dropdownItems[i].name === value) {
  318. return found.settings.dropdownItems[i]._id;
  319. }
  320. }
  321. }
  322. return value;
  323. }
  324. _arrayToSelector(commands) {
  325. try {
  326. //let changed = false;
  327. this._processSubCommands(commands);
  328. } catch (e) {
  329. return this._lastValide;
  330. }
  331. this._lastValide = {
  332. $or: commands,
  333. };
  334. return {
  335. $or: commands,
  336. };
  337. }
  338. _processSubCommands(commands) {
  339. const subcommands = [];
  340. let level = 0;
  341. let start = -1;
  342. for (let i = 0; i < commands.length; i++) {
  343. if (commands[i].cmd) {
  344. switch (commands[i].cmd) {
  345. case '(': {
  346. level++;
  347. if (start === -1) start = i;
  348. continue;
  349. }
  350. case ')': {
  351. level--;
  352. commands.splice(i, 1);
  353. i--;
  354. continue;
  355. }
  356. default: {
  357. if (level > 0) {
  358. subcommands.push(commands[i]);
  359. commands.splice(i, 1);
  360. i--;
  361. continue;
  362. }
  363. }
  364. }
  365. }
  366. }
  367. if (start !== -1) {
  368. this._processSubCommands(subcommands);
  369. if (subcommands.length === 1) commands.splice(start, 0, subcommands[0]);
  370. else commands.splice(start, 0, subcommands);
  371. }
  372. this._processConditions(commands);
  373. this._processLogicalOperators(commands);
  374. }
  375. _processConditions(commands) {
  376. for (let i = 0; i < commands.length; i++) {
  377. if (!commands[i].string && commands[i].cmd) {
  378. switch (commands[i].cmd) {
  379. case '=':
  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': regex,
  392. };
  393. } else {
  394. commands[i] = {
  395. 'customFields._id': this._fieldNameToId(field),
  396. 'customFields.value': {
  397. $in: [this._fieldValueToId(field, str), parseInt(str, 10)],
  398. },
  399. };
  400. }
  401. commands.splice(i - 1, 1);
  402. commands.splice(i, 1);
  403. //changed = true;
  404. i--;
  405. break;
  406. }
  407. case '!=':
  408. case '!==': {
  409. const field = commands[i - 1].cmd;
  410. const str = commands[i + 1].cmd;
  411. if (commands[i + 1].regex) {
  412. const match = str.match(new RegExp('^/(.*?)/([gimy]*)$'));
  413. let regex = null;
  414. if (match.length > 2) regex = new RegExp(match[1], match[2]);
  415. else regex = new RegExp(match[1]);
  416. commands[i] = {
  417. 'customFields._id': this._fieldNameToId(field),
  418. 'customFields.value': {
  419. $not: regex,
  420. },
  421. };
  422. } else {
  423. commands[i] = {
  424. 'customFields._id': this._fieldNameToId(field),
  425. 'customFields.value': {
  426. $not: {
  427. $in: [this._fieldValueToId(field, str), parseInt(str, 10)],
  428. },
  429. },
  430. };
  431. }
  432. commands.splice(i - 1, 1);
  433. commands.splice(i, 1);
  434. //changed = true;
  435. i--;
  436. break;
  437. }
  438. case '>':
  439. case 'gt':
  440. case 'Gt':
  441. case 'GT': {
  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. $gt: 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. case '>=':
  457. case '>==':
  458. case 'gte':
  459. case 'Gte':
  460. case 'GTE': {
  461. const field = commands[i - 1].cmd;
  462. const str = commands[i + 1].cmd;
  463. commands[i] = {
  464. 'customFields._id': this._fieldNameToId(field),
  465. 'customFields.value': {
  466. $gte: parseInt(str, 10),
  467. },
  468. };
  469. commands.splice(i - 1, 1);
  470. commands.splice(i, 1);
  471. //changed = true;
  472. i--;
  473. break;
  474. }
  475. case '<':
  476. case 'lt':
  477. case 'Lt':
  478. case 'LT': {
  479. const field = commands[i - 1].cmd;
  480. const str = commands[i + 1].cmd;
  481. commands[i] = {
  482. 'customFields._id': this._fieldNameToId(field),
  483. 'customFields.value': {
  484. $lt: parseInt(str, 10),
  485. },
  486. };
  487. commands.splice(i - 1, 1);
  488. commands.splice(i, 1);
  489. //changed = true;
  490. i--;
  491. break;
  492. }
  493. case '<=':
  494. case '<==':
  495. case 'lte':
  496. case 'Lte':
  497. case 'LTE': {
  498. const field = commands[i - 1].cmd;
  499. const str = commands[i + 1].cmd;
  500. commands[i] = {
  501. 'customFields._id': this._fieldNameToId(field),
  502. 'customFields.value': {
  503. $lte: parseInt(str, 10),
  504. },
  505. };
  506. commands.splice(i - 1, 1);
  507. commands.splice(i, 1);
  508. //changed = true;
  509. i--;
  510. break;
  511. }
  512. }
  513. }
  514. }
  515. }
  516. _processLogicalOperators(commands) {
  517. for (let i = 0; i < commands.length; i++) {
  518. if (!commands[i].string && commands[i].cmd) {
  519. switch (commands[i].cmd) {
  520. case 'or':
  521. case 'Or':
  522. case 'OR':
  523. case '|':
  524. case '||': {
  525. const op1 = commands[i - 1];
  526. const op2 = commands[i + 1];
  527. commands[i] = {
  528. $or: [op1, op2],
  529. };
  530. commands.splice(i - 1, 1);
  531. commands.splice(i, 1);
  532. //changed = true;
  533. i--;
  534. break;
  535. }
  536. case 'and':
  537. case 'And':
  538. case 'AND':
  539. case '&':
  540. case '&&': {
  541. const op1 = commands[i - 1];
  542. const op2 = commands[i + 1];
  543. commands[i] = {
  544. $and: [op1, op2],
  545. };
  546. commands.splice(i - 1, 1);
  547. commands.splice(i, 1);
  548. //changed = true;
  549. i--;
  550. break;
  551. }
  552. case 'not':
  553. case 'Not':
  554. case 'NOT':
  555. case '!': {
  556. const op1 = commands[i + 1];
  557. commands[i] = {
  558. $not: op1,
  559. };
  560. commands.splice(i + 1, 1);
  561. //changed = true;
  562. i--;
  563. break;
  564. }
  565. }
  566. }
  567. }
  568. }
  569. _getMongoSelector() {
  570. this._dep.depend();
  571. const commands = this._filterToCommands();
  572. return this._arrayToSelector(commands);
  573. }
  574. getRegexSelector() {
  575. // generate a regex for filter list
  576. this._dep.depend();
  577. return new RegExp(
  578. `^.*${this._filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`,
  579. 'i',
  580. );
  581. }
  582. }
  583. // The global Filter object.
  584. // XXX It would be possible to re-write this object more elegantly, and removing
  585. // the need to provide a list of `_fields`. We also should move methods into the
  586. // object prototype.
  587. Filter = {
  588. // XXX I would like to rename this field into `labels` to be consistent with
  589. // the rest of the schema, but we need to set some migrations architecture
  590. // before changing the schema.
  591. labelIds: new SetFilter(),
  592. members: new SetFilter(),
  593. assignees: new SetFilter(),
  594. archive: new SetFilter(),
  595. hideEmpty: new SetFilter(),
  596. dueAt: new DateFilter(),
  597. title: new StringFilter(),
  598. customFields: new SetFilter('_id'),
  599. advanced: new AdvancedFilter(),
  600. lists: new AdvancedFilter(), // we need the ability to filter list by name as well
  601. _fields: [
  602. 'labelIds',
  603. 'members',
  604. 'assignees',
  605. 'archive',
  606. 'hideEmpty',
  607. 'dueAt',
  608. 'title',
  609. 'customFields',
  610. ],
  611. // We don't filter cards that have been added after the last filter change. To
  612. // implement this we keep the id of these cards in this `_exceptions` fields
  613. // and use a `$or` condition in the mongo selector we return.
  614. _exceptions: [],
  615. _exceptionsDep: new Tracker.Dependency(),
  616. isActive() {
  617. return (
  618. _.any(this._fields, fieldName => {
  619. return this[fieldName]._isActive();
  620. }) ||
  621. this.advanced._isActive() ||
  622. this.lists._isActive()
  623. );
  624. },
  625. _getMongoSelector() {
  626. if (!this.isActive()) return {};
  627. const filterSelector = {};
  628. const emptySelector = {};
  629. let includeEmptySelectors = false;
  630. let isFilterActive = false; // we don't want there is only Filter.lists
  631. this._fields.forEach(fieldName => {
  632. const filter = this[fieldName];
  633. if (filter._isActive()) {
  634. isFilterActive = true;
  635. if (filter.subField !== '') {
  636. filterSelector[
  637. `${fieldName}.${filter.subField}`
  638. ] = filter._getMongoSelector();
  639. } else {
  640. filterSelector[fieldName] = filter._getMongoSelector();
  641. }
  642. emptySelector[fieldName] = filter._getEmptySelector();
  643. if (emptySelector[fieldName] !== null) {
  644. includeEmptySelectors = true;
  645. }
  646. }
  647. });
  648. const exceptionsSelector = {
  649. _id: {
  650. $in: this._exceptions,
  651. },
  652. };
  653. this._exceptionsDep.depend();
  654. const selectors = [exceptionsSelector];
  655. if (
  656. _.any(this._fields, fieldName => {
  657. return this[fieldName]._isActive();
  658. })
  659. )
  660. selectors.push(filterSelector);
  661. if (includeEmptySelectors) selectors.push(emptySelector);
  662. if (this.advanced._isActive()) {
  663. isFilterActive = true;
  664. selectors.push(this.advanced._getMongoSelector());
  665. }
  666. if(isFilterActive) {
  667. return {
  668. $or: selectors,
  669. };
  670. }
  671. else {
  672. // we don't want there is only Filter.lists
  673. // otherwise no card will be displayed ...
  674. // selectors = [exceptionsSelector];
  675. // will return [{"_id":{"$in":[]}}]
  676. return {};
  677. }
  678. },
  679. mongoSelector(additionalSelector) {
  680. const filterSelector = this._getMongoSelector();
  681. if (_.isUndefined(additionalSelector)) return filterSelector;
  682. else
  683. return {
  684. $and: [filterSelector, additionalSelector],
  685. };
  686. },
  687. reset() {
  688. this._fields.forEach(fieldName => {
  689. const filter = this[fieldName];
  690. filter.reset();
  691. });
  692. this.lists.reset();
  693. this.advanced.reset();
  694. this.resetExceptions();
  695. },
  696. addException(_id) {
  697. if (this.isActive()) {
  698. this._exceptions.push(_id);
  699. this._exceptionsDep.changed();
  700. Tracker.flush();
  701. }
  702. },
  703. resetExceptions() {
  704. this._exceptions = [];
  705. this._exceptionsDep.changed();
  706. },
  707. };
  708. Blaze.registerHelper('Filter', Filter);