filter.js 20 KB

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