Browse Source

Merge pull request #3617 from jrsupplee/search

Global Search enhancements and fixes
Lauri Ojansivu 4 years ago
parent
commit
8181074238

+ 25 - 23
client/components/main/globalSearch.jade

@@ -13,7 +13,7 @@ template(name="globalSearchModalTitle")
 template(name="globalSearch")
   if currentUser
     .wrapper
-      form.global-search-instructions.js-search-query-form
+      form.global-search-page.js-search-query-form
         input.global-search-query-input(
           id="global-search-input"
           type="text"
@@ -48,29 +48,31 @@ template(name="globalSearch")
                     button.js-next-page
                       | {{_ 'next-page' }}
       else
-        .global-search-instructions
-          h2 {{_ 'boards' }}
-          .lists-wrapper
-            each title in myBoardNames.get
-              span.card-label.list-title.js-board-title
-                = title
-          h2 {{_ 'lists' }}
-          .lists-wrapper
-            each title in myLists.get
-              span.card-label.list-title.js-list-title
-                = title
-          h2 {{_ 'label-colors' }}
-          .palette-colors: each label in labelColors
-            span.card-label.palette-color.js-label-color(class="card-label-{{label.color}}")
-              = label.name
-          if myLabelNames.get.length
-            h2 {{_ 'label-names' }}
+        .global-search-page
+          .global-search-help
+            h2 {{_ 'boards' }}
             .lists-wrapper
-              each name in myLabelNames.get
-                span.card-label.list-title.js-label-name
-                  = name
-          +viewer
-            = searchInstructions
+              each title in myBoardNames.get
+                span.card-label.list-title.js-board-title
+                  = title
+            h2 {{_ 'lists' }}
+            .lists-wrapper
+              each title in myLists.get
+                span.card-label.list-title.js-list-title
+                  = title
+            h2 {{_ 'label-colors' }}
+            .palette-colors: each label in labelColors
+              span.card-label.palette-color.js-label-color(class="card-label-{{label.color}}")
+                = label.name
+            if myLabelNames.get.length
+              h2 {{_ 'label-names' }}
+              .lists-wrapper
+                each name in myLabelNames.get
+                  span.card-label.list-title.js-label-name
+                    = name
+          .global-search-instructions
+            +viewer
+              = searchInstructions
 
 template(name="globalSearchViewChangePopup")
   if currentUser

+ 157 - 106
client/components/main/globalSearch.js

@@ -116,7 +116,9 @@ BlazeComponent.extendComponent({
       // eslint-disable-next-line no-console
       // console.log('selector:', sessionData.getSelector());
       // console.log('session data:', sessionData);
-      const cards = Cards.find({ _id: { $in: sessionData.cards } });
+      const projection = sessionData.getProjection();
+      projection.skip = 0;
+      const cards = Cards.find({ _id: { $in: sessionData.cards } }, projection);
       this.queryErrors = sessionData.errors;
       if (this.queryErrors.length) {
         this.hasQueryErrors.set(true);
@@ -201,6 +203,7 @@ BlazeComponent.extendComponent({
       '^(?<quote>["\'])(?<text>.*?)\\k<quote>(\\s+|$)',
       'u',
     );
+    const reNegatedOperator = new RegExp('^-(?<operator>.*)$');
 
     const operators = {
       'operator-board': 'boards',
@@ -223,6 +226,8 @@ BlazeComponent.extendComponent({
       'operator-modified': 'modifiedAt',
       'operator-comment': 'comments',
       'operator-has': 'has',
+      'operator-sort': 'sort',
+      'operator-limit': 'limit',
     };
 
     const predicates = {
@@ -238,6 +243,7 @@ BlazeComponent.extendComponent({
       status: {
         'predicate-archived': 'archived',
         'predicate-all': 'all',
+        'predicate-open': 'open',
         'predicate-ended': 'ended',
         'predicate-public': 'public',
         'predicate-private': 'private',
@@ -251,6 +257,11 @@ BlazeComponent.extendComponent({
         'predicate-description': 'description',
         'predicate-checklist': 'checklist',
         'predicate-attachment': 'attachment',
+        'predicate-start': 'startAt',
+        'predicate-end': 'endAt',
+        'predicate-due': 'dueAt',
+        'predicate-assignee': 'assignees',
+        'predicate-member': 'members',
       },
     };
     const predicateTranslations = {};
@@ -307,25 +318,65 @@ BlazeComponent.extendComponent({
         }
         // eslint-disable-next-line no-prototype-builtins
         if (operatorMap.hasOwnProperty(op)) {
+          const operator = operatorMap[op];
           let value = m.groups.value;
-          if (operatorMap[op] === 'labels') {
+          if (operator === 'labels') {
             if (value in this.colorMap) {
               value = this.colorMap[value];
               // console.log('found color:', value);
             }
-          } else if (
-            ['dueAt', 'createdAt', 'modifiedAt'].includes(operatorMap[op])
-          ) {
+          } else if (['dueAt', 'createdAt', 'modifiedAt'].includes(operator)) {
             let days = parseInt(value, 10);
             let duration = null;
             if (isNaN(days)) {
+              // duration was specified as text
               if (predicateTranslations.durations[value]) {
                 duration = predicateTranslations.durations[value];
-                value = moment();
-              } else if (predicateTranslations.due[value] === 'overdue') {
-                value = moment();
-                duration = 'days';
-                days = 0;
+                let date = null;
+                switch (duration) {
+                  case 'week':
+                    let week = moment().week();
+                    if (week === 52) {
+                      date = moment(1, 'W');
+                      date.set('year', date.year() + 1);
+                    } else {
+                      date = moment(week + 1, 'W');
+                    }
+                    break;
+                  case 'month':
+                    let month = moment().month();
+                    // .month() is zero indexed
+                    if (month === 11) {
+                      date = moment(1, 'M');
+                      date.set('year', date.year() + 1);
+                    } else {
+                      date = moment(month + 2, 'M');
+                    }
+                    break;
+                  case 'quarter':
+                    let quarter = moment().quarter();
+                    if (quarter === 4) {
+                      date = moment(1, 'Q');
+                      date.set('year', date.year() + 1);
+                    } else {
+                      date = moment(quarter + 1, 'Q');
+                    }
+                    break;
+                  case 'year':
+                    date = moment(moment().year() + 1, 'YYYY');
+                    break;
+                }
+                if (date) {
+                  value = {
+                    operator: '$lt',
+                    value: date.format('YYYY-MM-DD'),
+                  };
+                }
+              } else if (operator === 'dueAt' && value === 'overdue') {
+                value = {
+                  operator: '$lt',
+                  value: moment().format('YYYY-MM-DD'),
+                };
               } else {
                 this.parsingErrors.push({
                   tag: 'operator-number-expected',
@@ -334,27 +385,41 @@ BlazeComponent.extendComponent({
                 value = null;
               }
             } else {
-              value = moment();
-            }
-            if (value) {
-              if (operatorMap[op] === 'dueAt') {
-                value = value.add(days, duration ? duration : 'days').format();
+              if (operator === 'dueAt') {
+                value = {
+                  operator: '$lt',
+                  value: moment(moment().format('YYYY-MM-DD'))
+                    .add(days + 1, duration ? duration : 'days')
+                    .format(),
+                };
               } else {
-                value = value
-                  .subtract(days, duration ? duration : 'days')
-                  .format();
+                value = {
+                  operator: '$gte',
+                  value: moment(moment().format('YYYY-MM-DD'))
+                    .subtract(days, duration ? duration : 'days')
+                    .format(),
+                };
               }
             }
-          } else if (operatorMap[op] === 'sort') {
+          } else if (operator === 'sort') {
+            let negated = false;
+            const m = value.match(reNegatedOperator);
+            if (m) {
+              value = m.groups.operator;
+              negated = true;
+            }
             if (!predicateTranslations.sorts[value]) {
               this.parsingErrors.push({
                 tag: 'operator-sort-invalid',
                 value,
               });
             } else {
-              value = predicateTranslations.sorts[value];
+              value = {
+                name: predicateTranslations.sorts[value],
+                order: negated ? 'des' : 'asc',
+              };
             }
-          } else if (operatorMap[op] === 'status') {
+          } else if (operator === 'status') {
             if (!predicateTranslations.status[value]) {
               this.parsingErrors.push({
                 tag: 'operator-status-invalid',
@@ -363,20 +428,39 @@ BlazeComponent.extendComponent({
             } else {
               value = predicateTranslations.status[value];
             }
-          } else if (operatorMap[op] === 'has') {
+          } else if (operator === 'has') {
+            let negated = false;
+            const m = value.match(reNegatedOperator);
+            if (m) {
+              value = m.groups.operator;
+              negated = true;
+            }
             if (!predicateTranslations.has[value]) {
               this.parsingErrors.push({
                 tag: 'operator-has-invalid',
                 value,
               });
             } else {
-              value = predicateTranslations.has[value];
+              value = {
+                field: predicateTranslations.has[value],
+                exists: !negated,
+              };
+            }
+          } else if (operator === 'limit') {
+            const limit = parseInt(value, 10);
+            if (isNaN(limit) || limit < 1) {
+              this.parsingErrors.push({
+                tag: 'operator-limit-invalid',
+                value,
+              });
+            } else {
+              value = limit;
             }
           }
-          if (Array.isArray(params[operatorMap[op]])) {
-            params[operatorMap[op]].push(value);
+          if (Array.isArray(params[operator])) {
+            params[operator].push(value);
           } else {
-            params[operatorMap[op]] = value;
+            params[operator] = value;
           }
         } else {
           this.parsingErrors.push({
@@ -437,20 +521,10 @@ BlazeComponent.extendComponent({
   },
 
   nextPage() {
-    sessionData = this.getSessionData();
-
-    const params = {
-      limit: this.resultsPerPage,
-      selector: sessionData.getSelector(),
-      skip: sessionData.lastHit,
-    };
+    const sessionData = this.getSessionData();
 
     this.autorun(() => {
-      const handle = Meteor.subscribe(
-        'globalSearch',
-        SessionData.getSessionId(),
-        params,
-      );
+      const handle = Meteor.subscribe('nextPage', sessionData.sessionId);
       Tracker.nonreactive(() => {
         Tracker.autorun(() => {
           if (handle.ready()) {
@@ -464,21 +538,10 @@ BlazeComponent.extendComponent({
   },
 
   previousPage() {
-    sessionData = this.getSessionData();
-
-    const params = {
-      limit: this.resultsPerPage,
-      selector: sessionData.getSelector(),
-      skip:
-        sessionData.lastHit - sessionData.resultsCount - this.resultsPerPage,
-    };
+    const sessionData = this.getSessionData();
 
     this.autorun(() => {
-      const handle = Meteor.subscribe(
-        'globalSearch',
-        SessionData.getSessionId(),
-        params,
-      );
+      const handle = Meteor.subscribe('previousPage', sessionData.sessionId);
       Tracker.nonreactive(() => {
         Tracker.autorun(() => {
           if (handle.ready()) {
@@ -531,6 +594,8 @@ BlazeComponent.extendComponent({
       operator_modified: TAPi18n.__('operator-modified'),
       operator_status: TAPi18n.__('operator-status'),
       operator_has: TAPi18n.__('operator-has'),
+      operator_sort: TAPi18n.__('operator-sort'),
+      operator_limit: TAPi18n.__('operator-limit'),
       predicate_overdue: TAPi18n.__('predicate-overdue'),
       predicate_archived: TAPi18n.__('predicate-archived'),
       predicate_all: TAPi18n.__('predicate-all'),
@@ -544,81 +609,67 @@ BlazeComponent.extendComponent({
       predicate_checklist: TAPi18n.__('predicate-checklist'),
       predicate_public: TAPi18n.__('predicate-public'),
       predicate_private: TAPi18n.__('predicate-private'),
+      predicate_due: TAPi18n.__('predicate-due'),
+      predicate_created: TAPi18n.__('predicate-created'),
+      predicate_modified: TAPi18n.__('predicate-modified'),
+      predicate_start: TAPi18n.__('predicate-start'),
+      predicate_end: TAPi18n.__('predicate-end'),
+      predicate_assignee: TAPi18n.__('predicate-assignee'),
+      predicate_member: TAPi18n.__('predicate-member'),
     };
 
-    text = `# ${TAPi18n.__('globalSearch-instructions-heading')}`;
+    let text = `# ${TAPi18n.__('globalSearch-instructions-heading')}`;
     text += `\n${TAPi18n.__('globalSearch-instructions-description', tags)}`;
-    text += `\n${TAPi18n.__('globalSearch-instructions-operators', tags)}`;
-    text += `\n* ${TAPi18n.__(
+    text += `\n\n${TAPi18n.__('globalSearch-instructions-operators', tags)}`;
+
+    [
       'globalSearch-instructions-operator-board',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-list',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-swimlane',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-comment',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-label',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-hash',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-user',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-operator-at', tags)}`;
-    text += `\n* ${TAPi18n.__(
+      'globalSearch-instructions-operator-at',
       'globalSearch-instructions-operator-member',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-assignee',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-operator-due', tags)}`;
-    text += `\n* ${TAPi18n.__(
+      'globalSearch-instructions-operator-due',
       'globalSearch-instructions-operator-created',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-operator-modified',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
+      'globalSearch-instructions-operator-status',
+    ].forEach(instruction => {
+      text += `\n* ${TAPi18n.__(instruction, tags)}`;
+    });
+
+    [
       'globalSearch-instructions-status-archived',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-status-public',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__(
       'globalSearch-instructions-status-private',
-      tags,
-    )}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-status-all', tags)}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-status-ended', tags)}`;
+      'globalSearch-instructions-status-all',
+      'globalSearch-instructions-status-ended',
+    ].forEach(instruction => {
+      text += `\n    * ${TAPi18n.__(instruction, tags)}`;
+    });
 
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-operator-has', tags)}`;
+    [
+      'globalSearch-instructions-operator-has',
+      'globalSearch-instructions-operator-sort',
+      'globalSearch-instructions-operator-limit'
+    ].forEach(instruction => {
+      text += `\n* ${TAPi18n.__(instruction, tags)}`;
+    });
 
     text += `\n## ${TAPi18n.__('heading-notes')}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-1', tags)}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-2', tags)}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-3', tags)}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-3-2', tags)}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-4', tags)}`;
-    text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-5', tags)}`;
+    [
+      'globalSearch-instructions-notes-1',
+      'globalSearch-instructions-notes-2',
+      'globalSearch-instructions-notes-3',
+      'globalSearch-instructions-notes-3-2',
+      'globalSearch-instructions-notes-4',
+      'globalSearch-instructions-notes-5',
+    ].forEach(instruction => {
+      text += `\n* ${TAPi18n.__(instruction, tags)}`;
+    });
 
     return text;
   },

+ 7 - 4
client/components/main/globalSearch.styl

@@ -71,17 +71,17 @@
 .global-search-error-messages
   color: darkred
 
-.global-search-instructions
+.global-search-page
   width: 40%
   min-width: 400px
   margin-right: auto
   margin-left: auto
   line-height: 150%
 
-.global-search-instructions h1
+.global-search-page h1
   margin-top: 2rem;
 
-.global-search-instructions h2
+.global-search-page h2
   margin-top: 1rem;
 
 .global-search-query-input
@@ -100,7 +100,7 @@ code
   color: black
   background-color: lightgrey
   padding: 0.1rem !important
-  font-size: 0.7rem !important
+  font-size: 0.8rem !important
 
 .list-title
   background-color: darkgray
@@ -116,3 +116,6 @@ code
 .global-search-previous-page
   border: none
   text-align: left;
+
+.global-search-instructions li
+  margin-bottom: 0.3rem

+ 30 - 20
i18n/en.i18n.json

@@ -907,7 +907,9 @@
   "operator-sort": "sort",
   "operator-comment": "comment",
   "operator-has": "has",
+  "operator-limit": "limit",
   "predicate-archived": "archived",
+  "predicate-open": "open",
   "predicate-ended": "ended",
   "predicate-all": "all",
   "predicate-overdue": "overdue",
@@ -921,6 +923,10 @@
   "predicate-attachment": "attachment",
   "predicate-description": "description",
   "predicate-checklist": "checklist",
+  "predicate-start": "start",
+  "predicate-end": "end",
+  "predicate-assignee": "assignee",
+  "predicate-member": "member",
   "predicate-public": "public",
   "predicate-private": "private",
   "operator-unknown-error": "%s is not an operator",
@@ -928,35 +934,39 @@
   "operator-sort-invalid": "sort of '%s' is invalid",
   "operator-status-invalid": "'%s' is not a valid status",
   "operator-has-invalid": "%s is not a valid existence check",
+  "operator-limit-invalid": "%s is not a valid limit.  Limit should be a positive integer.",
   "next-page": "Next Page",
   "previous-page": "Previous Page",
   "heading-notes": "Notes",
   "globalSearch-instructions-heading": "Search Instructions",
   "globalSearch-instructions-description": "Searches can include operators to refine the search.  Operators are specified by writing the operator name and value separated by a colon.  For example, an operator specification of `list:Blocked` would limit the search to cards that are contained in a list named *Blocked*.  If the value contains spaces or special characters it must be enclosed in quotation marks (e.g. `__operator_list__:\"To Review\"`).",
   "globalSearch-instructions-operators": "Available operators:",
-  "globalSearch-instructions-operator-board": "`__operator_board__:title` - cards in boards matching the specified title",
-  "globalSearch-instructions-operator-list": "`__operator_list__:title` - cards in lists matching the specified title",
-  "globalSearch-instructions-operator-swimlane": "`__operator_swimlane__:title` - cards in swimlanes matching the specified title",
-  "globalSearch-instructions-operator-comment": "`__operator_comment__:text` - cards with a comment containing *text*.",
-  "globalSearch-instructions-operator-label": "`__operator_label__:color` `__operator_label__:name` - cards that have a label matching the given color or name",
-  "globalSearch-instructions-operator-hash": "`__operator_label_abbrev__label` - shorthand for `__operator_label__:label`",
-  "globalSearch-instructions-operator-user": "`__operator_user__:username` - cards where the specified user is a *member* or *assignee*",
-  "globalSearch-instructions-operator-at": "`__operator_user_abbrev__username` - shorthand for `user:username`",
-  "globalSearch-instructions-operator-member": "`__operator_member__:username` - cards where the specified user is a *member*",
-  "globalSearch-instructions-operator-assignee": "`__operator_assignee__:username` - cards where the specified user is an *assignee*",
-  "globalSearch-instructions-operator-due": "`__operator_due__:n` - cards which are due *n* days from now.  `__operator_due__:__predicate_overdue__ lists all cards past their due date.",
-  "globalSearch-instructions-operator-created": "`__operator_created__:n` - cards which were created *n* days ago",
-  "globalSearch-instructions-operator-modified": "`__operator_modified__:n` - cards which were modified *n* days ago",
-  "globalSearch-instructions-status-archived": "`__operator_status__:__predicate_archived__` - cards that are archived.",
-  "globalSearch-instructions-status-all": "`__operator_status__:__predicate_all__` - all archived and unarchived cards.",
-  "globalSearch-instructions-status-ended": "`__operator_status__:__predicate_ended__` - cards with an end date.",
-  "globalSearch-instructions-status-public": "`__operator_status__:__predicate_public__` - cards only in public boards.",
-  "globalSearch-instructions-status-private": "`__operator_status__:__predicate_private__` - cards only in private boards.",
-  "globalSearch-instructions-operator-has": "`__operator_has__:field` - where *field* is one of `__predicate_attachment__`, `__predicate_checklist__` or `__predicate_description__`",
+  "globalSearch-instructions-operator-board": "`__operator_board__:<title>` - cards in boards matching the specified *<title>*",
+  "globalSearch-instructions-operator-list": "`__operator_list__:<title>` - cards in lists matching the specified *<title>*",
+  "globalSearch-instructions-operator-swimlane": "`__operator_swimlane__:<title>` - cards in swimlanes matching the specified *<title>*",
+  "globalSearch-instructions-operator-comment": "`__operator_comment__:<text>` - cards with a comment containing *<text>*.",
+  "globalSearch-instructions-operator-label": "`__operator_label__:<color>` `__operator_label__:<name>` - cards that have a label matching *<color>* or *<name>",
+  "globalSearch-instructions-operator-hash": "`__operator_label_abbrev__<name | color>` - shorthand for `__operator_label__:<color>` or `__operator_label__:<name>`",
+  "globalSearch-instructions-operator-user": "`__operator_user__:<username>` - cards where *<username>* is a *member* or *assignee*",
+  "globalSearch-instructions-operator-at": "`__operator_user_abbrev__username` - shorthand for `user:<username>`",
+  "globalSearch-instructions-operator-member": "`__operator_member__:<username>` - cards where *<username>* is a *member*",
+  "globalSearch-instructions-operator-assignee": "`__operator_assignee__:<username>` - cards where *<username>* is an *assignee*",
+  "globalSearch-instructions-operator-due": "`__operator_due__:<n>` - cards which are due up to *<n>* days from now.  `__operator_due__:__predicate_overdue__ lists all cards past their due date.",
+  "globalSearch-instructions-operator-created": "`__operator_created__:<n>` - cards which were created *<n>* days ago or less",
+  "globalSearch-instructions-operator-modified": "`__operator_modified__:<n>` - cards which were modified *<n>* days ago or less",
+  "globalSearch-instructions-operator-status": "`__operator_status__:<status>` - where *<status>* is one of the following:",
+  "globalSearch-instructions-status-archived": "`__predicate_archived__` - archived cards",
+  "globalSearch-instructions-status-all": "`__predicate_all__` - all archived and unarchived cards",
+  "globalSearch-instructions-status-ended": "`__predicate_ended__` - cards with an end date",
+  "globalSearch-instructions-status-public": "`__predicate_public__` - cards only in public boards",
+  "globalSearch-instructions-status-private": "`__predicate_private__` - cards only in private boards",
+  "globalSearch-instructions-operator-has": "`__operator_has__:<field>` - where *<field>* is one of `__predicate_attachment__`, `__predicate_checklist__`, `__predicate_description__`, `__predicate_start__`, `__predicate_due__`, `__predicate_end__`, `__predicate_assignee__` or `__predicate_member__`.  Placing a `-` in front of *<field>* searches for the absence of a value in that field (e.g. `has:-due` searches for cards without a due date).",
+  "globalSearch-instructions-operator-sort": "`__operator_sort__:<sort-name>` - where *<sort-name>* is one of `__predicate_due__`, `__predicate_created__` or `__predicate_modified__`.  For a descending sort, place a `-` in front of the sort name.",
+  "globalSearch-instructions-operator-limit": "`__operator_limit__:<n>` - where *<n>* is a positive integer expressing the number of cards to be displayed per page.",
   "globalSearch-instructions-notes-1": "Multiple operators may be specified.",
   "globalSearch-instructions-notes-2": "Similar operators are *OR*ed together.  Cards that match any of the conditions will be returned.\n`__operator_list__:Available __operator_list__:Blocked` would return cards contained in any list named *Blocked* or *Available*.",
   "globalSearch-instructions-notes-3": "Differing operators are *AND*ed together.  Only cards that match all of the differing operators are returned.  `__operator_list__:Available __operator_label__:red` returns only cards in the list *Available* with a *red* label.",
-  "globalSearch-instructions-notes-3-2": "Days can be specified as an integer or using `__predicate_week__`, `__predicate_month__`, `__predicate_quarter__` or `__predicate_year__`",
+  "globalSearch-instructions-notes-3-2": "Days can be specified as a positive or negative integer or using `__predicate_week__`, `__predicate_month__`, `__predicate_quarter__` or `__predicate_year__` for the current period.",
   "globalSearch-instructions-notes-4": "Text searches are case insensitive.",
   "globalSearch-instructions-notes-5": "By default archived cards are not searched.",
   "link-to-search": "Link to this search",

+ 1 - 1
models/cardComments.js

@@ -117,7 +117,7 @@ CardComments.textSearch = (userId, textArray) => {
   };
 
   for (const text of textArray) {
-    selector.$and.push({ text: new RegExp(escapeForRegex(text)) });
+    selector.$and.push({ text: new RegExp(escapeForRegex(text), 'i') });
   }
 
   // eslint-disable-next-line no-console

+ 62 - 16
models/usersessiondata.js

@@ -62,6 +62,12 @@ SessionData.attachSchema(
       optional: true,
       blackbox: true,
     },
+    projection: {
+      type: String,
+      optional: true,
+      blackbox: true,
+      defaultValue: {},
+    },
     errorMessages: {
       type: [String],
       optional: true,
@@ -130,40 +136,80 @@ SessionData.helpers({
   getSelector() {
     return SessionData.unpickle(this.selector);
   },
+  getProjection() {
+    return SessionData.unpickle(this.projection);
+  },
 });
 
 SessionData.unpickle = pickle => {
   return JSON.parse(pickle, (key, value) => {
-    if (value === null) {
-      return null;
-    } else if (typeof value === 'object') {
-      // eslint-disable-next-line no-prototype-builtins
-      if (value.hasOwnProperty('$$class')) {
-        if (value.$$class === 'RegExp') {
+    return unpickleValue(value);
+  });
+};
+
+function unpickleValue(value) {
+  if (value === null) {
+    return null;
+  } else if (typeof value === 'object') {
+    // eslint-disable-next-line no-prototype-builtins
+    if (value.hasOwnProperty('$$class')) {
+      switch (value.$$class) {
+        case 'RegExp':
           return new RegExp(value.source, value.flags);
-        }
+        case 'Date':
+          return new Date(value.stringValue);
+        case 'Object':
+          return unpickleObject(value);
       }
     }
-    return value;
+  }
+  return value;
+}
+
+function unpickleObject(obj) {
+  const newObject = {};
+  Object.entries(obj).forEach(([key, value]) => {
+    newObject[key] = unpickleValue(value);
   });
-};
+  return newObject;
+}
 
 SessionData.pickle = value => {
   return JSON.stringify(value, (key, value) => {
-    if (value === null) {
-      return null;
-    } else if (typeof value === 'object') {
-      if (value.constructor.name === 'RegExp') {
+    return pickleValue(value);
+  });
+};
+
+function pickleValue(value) {
+  if (value === null) {
+    return null;
+  } else if (typeof value === 'object') {
+    switch(value.constructor.name) {
+      case 'RegExp':
         return {
           $$class: 'RegExp',
           source: value.source,
           flags: value.flags,
         };
-      }
+      case 'Date':
+        return {
+          $$class: 'Date',
+          stringValue: String(value),
+        };
+      case 'Object':
+        return pickleObject(value);
     }
-    return value;
+  }
+  return value;
+}
+
+function pickleObject(obj) {
+  const newObject = {};
+  Object.entries(obj).forEach(([key, value]) => {
+    newObject[key] = pickleValue(value);
   });
-};
+  return newObject;
+}
 
 if (!Meteor.isServer) {
   SessionData.getSessionId = () => {

+ 15 - 7
package-lock.json

@@ -718,6 +718,19 @@
       "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
       "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
     },
+    "babel-eslint": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz",
+      "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==",
+      "requires": {
+        "@babel/code-frame": "^7.0.0",
+        "@babel/parser": "^7.7.0",
+        "@babel/traverse": "^7.7.0",
+        "@babel/types": "^7.7.0",
+        "eslint-visitor-keys": "^1.0.0",
+        "resolve": "^1.12.0"
+      }
+    },
     "babel-runtime": {
       "version": "6.26.0",
       "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
@@ -2384,8 +2397,7 @@
     "function-bind": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
-      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
-      "dev": true
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
     },
     "functional-red-black-tree": {
       "version": "1.0.1",
@@ -2525,7 +2537,6 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
       "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
-      "dev": true,
       "requires": {
         "function-bind": "^1.1.1"
       }
@@ -2810,7 +2821,6 @@
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz",
       "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==",
-      "dev": true,
       "requires": {
         "has": "^1.0.3"
       }
@@ -4941,8 +4951,7 @@
     "path-parse": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
-      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
-      "dev": true
+      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
     },
     "path-to-regexp": {
       "version": "1.2.1",
@@ -5625,7 +5634,6 @@
       "version": "1.20.0",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
       "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
-      "dev": true,
       "requires": {
         "is-core-module": "^2.2.0",
         "path-parse": "^1.0.6"

+ 226 - 156
server/publications/cards.js

@@ -395,17 +395,14 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) {
       }
     }
 
-    if (queryParams.dueAt !== null) {
-      selector.dueAt = { $lte: new Date(queryParams.dueAt) };
-    }
-
-    if (queryParams.createdAt !== null) {
-      selector.createdAt = { $gte: new Date(queryParams.createdAt) };
-    }
-
-    if (queryParams.modifiedAt !== null) {
-      selector.modifiedAt = { $gte: new Date(queryParams.modifiedAt) };
-    }
+    ['dueAt', 'createdAt', 'modifiedAt'].forEach(field => {
+      if (queryParams[field]) {
+        selector[field] = {};
+        selector[field][queryParams[field]['operator']] = new Date(
+          queryParams[field]['value'],
+        );
+      }
+    });
 
     const queryMembers = [];
     const queryAssignees = [];
@@ -521,14 +518,33 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) {
 
     if (queryParams.has.length) {
       queryParams.has.forEach(has => {
-        if (has === 'description') {
-          selector.description = { $exists: true, $nin: [null, ''] };
-        } else if (has === 'attachment') {
-          const attachments = Attachments.find({}, { fields: { cardId: 1 } });
-          selector.$and.push({ _id: { $in: attachments.map(a => a.cardId) } });
-        } else if (has === 'checklist') {
-          const checklists = Checklists.find({}, { fields: { cardId: 1 } });
-          selector.$and.push({ _id: { $in: checklists.map(a => a.cardId) } });
+        switch (has.field) {
+          case 'attachment':
+            const attachments = Attachments.find({}, { fields: { cardId: 1 } });
+            selector.$and.push({ _id: { $in: attachments.map(a => a.cardId) } });
+            break;
+          case 'checklist':
+            const checklists = Checklists.find({}, { fields: { cardId: 1 } });
+            selector.$and.push({ _id: { $in: checklists.map(a => a.cardId) } });
+            break;
+          case 'description':
+          case 'startAt':
+          case 'dueAt':
+          case 'endAt':
+            if (has.exists) {
+              selector[has.field] = { $exists: true, $nin: [null, ''] };
+            } else {
+              selector[has.field] = { $in: [null, ''] };
+            }
+            break;
+          case 'assignees':
+          case 'members':
+            if (has.exists) {
+              selector[has.field] = { $exists: true, $nin: [null, []] };
+            } else {
+              selector[has.field] = { $in: [null, []] };
+            }
+            break;
         }
       });
     }
@@ -552,6 +568,11 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) {
 
       const attachments = Attachments.find({ 'original.name': regex });
 
+      // const comments = CardComments.find(
+      //   { text: regex },
+      //   { fields: { cardId: 1 } },
+      // );
+
       selector.$and.push({
         $or: [
           { title: regex },
@@ -566,6 +587,7 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) {
           },
           { _id: { $in: checklists.map(list => list.cardId) } },
           { _id: { $in: attachments.map(attach => attach.cardId) } },
+          // { _id: { $in: comments.map(com => com.cardId) } },
         ],
       });
     }
@@ -580,73 +602,186 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) {
   // eslint-disable-next-line no-console
   // console.log('selector.$and:', selector.$and);
 
-  let cards = null;
-
-  if (!errors.hasErrors()) {
-    const projection = {
-      fields: {
-        _id: 1,
-        archived: 1,
-        boardId: 1,
-        swimlaneId: 1,
-        listId: 1,
-        title: 1,
-        type: 1,
-        sort: 1,
-        members: 1,
-        assignees: 1,
-        colors: 1,
-        dueAt: 1,
-        createdAt: 1,
-        modifiedAt: 1,
-        labelIds: 1,
-        customFields: 1,
-      },
-      skip,
-      limit,
-    };
+  const projection = {
+    fields: {
+      _id: 1,
+      archived: 1,
+      boardId: 1,
+      swimlaneId: 1,
+      listId: 1,
+      title: 1,
+      type: 1,
+      sort: 1,
+      members: 1,
+      assignees: 1,
+      colors: 1,
+      dueAt: 1,
+      createdAt: 1,
+      modifiedAt: 1,
+      labelIds: 1,
+      customFields: 1,
+    },
+    sort: {
+      boardId: 1,
+      swimlaneId: 1,
+      listId: 1,
+      sort: 1,
+    },
+    skip,
+    limit,
+  };
 
-    if (queryParams.sort === 'due') {
-      projection.sort = {
-        dueAt: 1,
-        boardId: 1,
-        swimlaneId: 1,
-        listId: 1,
-        sort: 1,
-      };
-    } else if (queryParams.sort === 'modified') {
-      projection.sort = {
-        modifiedAt: -1,
-        boardId: 1,
-        swimlaneId: 1,
-        listId: 1,
-        sort: 1,
-      };
-    } else if (queryParams.sort === 'created') {
-      projection.sort = {
-        createdAt: -1,
-        boardId: 1,
-        swimlaneId: 1,
-        listId: 1,
-        sort: 1,
-      };
-    } else if (queryParams.sort === 'system') {
-      projection.sort = {
-        boardId: 1,
-        swimlaneId: 1,
-        listId: 1,
-        modifiedAt: 1,
-        sort: 1,
-      };
+  if (queryParams.sort) {
+    const order = queryParams.sort.order === 'asc' ? 1 : -1;
+    switch (queryParams.sort.name) {
+      case 'dueAt':
+        projection.sort = {
+          dueAt: order,
+          boardId: 1,
+          swimlaneId: 1,
+          listId: 1,
+          sort: 1,
+        };
+        break;
+      case 'modifiedAt':
+        projection.sort = {
+          modifiedAt: order,
+          boardId: 1,
+          swimlaneId: 1,
+          listId: 1,
+          sort: 1,
+        };
+        break;
+      case 'createdAt':
+        projection.sort = {
+          createdAt: order,
+          boardId: 1,
+          swimlaneId: 1,
+          listId: 1,
+          sort: 1,
+        };
+        break;
+      case 'system':
+        projection.sort = {
+          boardId: order,
+          swimlaneId: order,
+          listId: order,
+          modifiedAt: order,
+          sort: order,
+        };
+        break;
     }
+  }
 
-    // eslint-disable-next-line no-console
-    // console.log('projection:', projection);
-    cards = Cards.find(selector, projection);
+  // eslint-disable-next-line no-console
+  // console.log('projection:', projection);
+
+  return findCards(sessionId, selector, projection, errors);
+});
+
+Meteor.publish('brokenCards', function() {
+  const user = Users.findOne({ _id: this.userId });
 
-    // eslint-disable-next-line no-console
-    // console.log('count:', cards.count());
+  const permiitedBoards = [null];
+  let selector = {};
+  selector.$or = [
+    { permission: 'public' },
+    { members: { $elemMatch: { userId: user._id, isActive: true } } },
+  ];
+
+  Boards.find(selector).forEach(board => {
+    permiitedBoards.push(board._id);
+  });
+
+  selector = {
+    boardId: { $in: permiitedBoards },
+    $or: [
+      { boardId: { $in: [null, ''] } },
+      { swimlaneId: { $in: [null, ''] } },
+      { listId: { $in: [null, ''] } },
+    ],
+  };
+
+  const cards = Cards.find(selector, {
+    fields: {
+      _id: 1,
+      archived: 1,
+      boardId: 1,
+      swimlaneId: 1,
+      listId: 1,
+      title: 1,
+      type: 1,
+      sort: 1,
+      members: 1,
+      assignees: 1,
+      colors: 1,
+      dueAt: 1,
+    },
+  });
+
+  const boards = [];
+  const swimlanes = [];
+  const lists = [];
+  const users = [];
+
+  cards.forEach(card => {
+    if (card.boardId) boards.push(card.boardId);
+    if (card.swimlaneId) swimlanes.push(card.swimlaneId);
+    if (card.listId) lists.push(card.listId);
+    if (card.members) {
+      card.members.forEach(userId => {
+        users.push(userId);
+      });
+    }
+    if (card.assignees) {
+      card.assignees.forEach(userId => {
+        users.push(userId);
+      });
+    }
+  });
+
+  return [
+    cards,
+    Boards.find({ _id: { $in: boards } }),
+    Swimlanes.find({ _id: { $in: swimlanes } }),
+    Lists.find({ _id: { $in: lists } }),
+    Users.find({ _id: { $in: users } }, { fields: Users.safeFields }),
+  ];
+});
+
+Meteor.publish('nextPage', function(sessionId) {
+  check(sessionId, String);
+
+  const session = SessionData.findOne({ sessionId });
+  const projection = session.getProjection();
+  projection.skip = session.lastHit;
+
+  return findCards(sessionId, session.getSelector(), projection);
+});
+
+Meteor.publish('previousPage', function(sessionId) {
+  check(sessionId, String);
+
+  const session = SessionData.findOne({ sessionId });
+  const projection = session.getProjection();
+  projection.skip = session.lastHit - session.resultsCount - projection.limit;
+
+  return findCards(sessionId, session.getSelector(), projection);
+});
+
+function findCards(sessionId, selector, projection, errors = null) {
+  const userId = Meteor.userId();
+
+  // eslint-disable-next-line no-console
+  // console.log('selector:', selector);
+  // eslint-disable-next-line no-console
+  // console.log('projection:', projection);
+  let cards;
+  if (!errors || !errors.hasErrors()) {
+    cards = Cards.find(selector, projection);
   }
+  // eslint-disable-next-line no-console
+  // console.log('count:', cards.count());
 
   const update = {
     $set: {
@@ -654,15 +789,20 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) {
       lastHit: 0,
       resultsCount: 0,
       cards: [],
-      errors: errors.errorMessages(),
       selector: SessionData.pickle(selector),
+      projection: SessionData.pickle(projection),
     },
   };
+  if (errors) {
+    update.$set.errors = errors.errorMessages();
+  }
 
   if (cards) {
     update.$set.totalHits = cards.count();
     update.$set.lastHit =
-      skip + limit < cards.count() ? skip + limit : cards.count();
+      projection.skip + projection.limit < cards.count()
+        ? projection.skip + projection.limit
+        : cards.count();
     update.$set.cards = cards.map(card => {
       return card._id;
     });
@@ -735,79 +875,9 @@ Meteor.publish('globalSearch', function(sessionId, queryParams) {
       Checklists.find({ cardId: { $in: cards.map(c => c._id) } }),
       Attachments.find({ cardId: { $in: cards.map(c => c._id) } }),
       CardComments.find({ cardId: { $in: cards.map(c => c._id) } }),
-      SessionData.find({ userId: this.userId, sessionId }),
+      SessionData.find({ userId, sessionId }),
     ];
   }
 
-  return [SessionData.find({ userId: this.userId, sessionId })];
-});
-
-Meteor.publish('brokenCards', function() {
-  const user = Users.findOne({ _id: this.userId });
-
-  const permiitedBoards = [null];
-  let selector = {};
-  selector.$or = [
-    { permission: 'public' },
-    { members: { $elemMatch: { userId: user._id, isActive: true } } },
-  ];
-
-  Boards.find(selector).forEach(board => {
-    permiitedBoards.push(board._id);
-  });
-
-  selector = {
-    boardId: { $in: permiitedBoards },
-    $or: [
-      { boardId: { $in: [null, ''] } },
-      { swimlaneId: { $in: [null, ''] } },
-      { listId: { $in: [null, ''] } },
-    ],
-  };
-
-  const cards = Cards.find(selector, {
-    fields: {
-      _id: 1,
-      archived: 1,
-      boardId: 1,
-      swimlaneId: 1,
-      listId: 1,
-      title: 1,
-      type: 1,
-      sort: 1,
-      members: 1,
-      assignees: 1,
-      colors: 1,
-      dueAt: 1,
-    },
-  });
-
-  const boards = [];
-  const swimlanes = [];
-  const lists = [];
-  const users = [];
-
-  cards.forEach(card => {
-    if (card.boardId) boards.push(card.boardId);
-    if (card.swimlaneId) swimlanes.push(card.swimlaneId);
-    if (card.listId) lists.push(card.listId);
-    if (card.members) {
-      card.members.forEach(userId => {
-        users.push(userId);
-      });
-    }
-    if (card.assignees) {
-      card.assignees.forEach(userId => {
-        users.push(userId);
-      });
-    }
-  });
-
-  return [
-    cards,
-    Boards.find({ _id: { $in: boards } }),
-    Swimlanes.find({ _id: { $in: swimlanes } }),
-    Lists.find({ _id: { $in: lists } }),
-    Users.find({ _id: { $in: users } }, { fields: Users.safeFields }),
-  ];
-});
+  return [SessionData.find({ userId: userId, sessionId })];
+}