Browse Source

Global Search improvements

* support for searching from the URL
* add support for searching by assignee and member
John R. Supplee 4 years ago
parent
commit
d74dc92681

+ 8 - 7
client/components/main/globalSearch.jade

@@ -14,20 +14,21 @@ template(name="globalSearch")
   if currentUser
     .wrapper
       form.global-search-instructions.js-search-query-form
-        input.global-search-query-input(type="text" name="searchQuery" placeholder="{{_ 'search-example'}}" autofocus dir="auto")
+        input.global-search-query-input(type="text" name="searchQuery" placeholder="{{_ 'search-example'}}" value="{{ query.get }}" autofocus dir="auto")
       if searching.get
         +spinner
       else if hasResults.get
-        .global-search-dueat-list-wrapper
-          h1
-            = resultsHeading
-          if queryErrors.get
+        .global-search-results-list-wrapper
+          if hasQueryErrors.get
             div
               each msg in errorMessages
                 span.global-search-error-messages
                   | {{_ msg.tag msg.value }}
-          each card in results
-            +resultCard(card)
+          else
+            h1
+              = resultsHeading.get
+            each card in results
+              +resultCard(card)
       else
         .global-search-instructions
           +viewer

+ 204 - 140
client/components/main/globalSearch.js

@@ -36,69 +36,225 @@ BlazeComponent.extendComponent({
 
 BlazeComponent.extendComponent({
   onCreated() {
-    this.isPageReady = new ReactiveVar(true);
     this.searching = new ReactiveVar(false);
     this.hasResults = new ReactiveVar(false);
+    this.hasQueryErrors = new ReactiveVar(false);
     this.query = new ReactiveVar('');
+    this.resultsHeading = new ReactiveVar('');
     this.queryParams = null;
-    this.resultsCount = new ReactiveVar(0);
-    this.totalHits = new ReactiveVar(0);
-    this.queryErrors = new ReactiveVar(null);
+    this.parsingErrors = [];
+    this.resultsCount = 0;
+    this.totalHits = 0;
+    this.queryErrors = null;
     Meteor.subscribe('setting');
+    if (Session.get('globalQuery')) {
+      // eslint-disable-next-line no-console
+      // console.log(Session.get('globalQuery'));
+      this.searchAllBoards(Session.get('globalQuery'));
+    }
+  },
+
+  resetSearch() {
+    this.searching.set(false);
+    this.hasResults.set(false);
+    this.hasQueryErrors.set(false);
+    this.resultsHeading.set('');
+    this.parsingErrors = [];
+    this.resultsCount = 0;
+    this.totalHits = 0;
+    this.queryErrors = null;
   },
 
   results() {
+    // eslint-disable-next-line no-console
+    console.log('getting results');
     if (this.queryParams) {
       const results = Cards.globalSearch(this.queryParams);
-      const sessionData = SessionData.findOne({ userId: Meteor.userId() });
+      this.queryErrors = results.errors;
       // eslint-disable-next-line no-console
-      // console.log('sessionData:', sessionData);
-      // console.log('errors:', results.errors);
-      this.totalHits.set(sessionData.totalHits);
-      this.resultsCount.set(results.cards.count());
-      this.queryErrors.set(results.errors);
-      return results.cards;
+      console.log('errors:', this.queryErrors);
+      if (this.errorMessages().length) {
+        this.hasQueryErrors.set(true);
+        return null;
+      }
+
+      if (results.cards) {
+        const sessionData = SessionData.findOne({ userId: Meteor.userId() });
+        this.totalHits = sessionData.totalHits;
+        this.resultsCount = results.cards.count();
+        this.resultsHeading.set(this.getResultsHeading());
+        return results.cards;
+      }
     }
-    this.resultsCount.set(0);
+    this.resultsCount = 0;
     return [];
   },
 
   errorMessages() {
-    const errors = this.queryErrors.get();
     const messages = [];
 
-    errors.notFound.boards.forEach(board => {
-      messages.push({ tag: 'board-title-not-found', value: board });
-    });
-    errors.notFound.swimlanes.forEach(swim => {
-      messages.push({ tag: 'swimlane-title-not-found', value: swim });
-    });
-    errors.notFound.lists.forEach(list => {
-      messages.push({ tag: 'list-title-not-found', value: list });
-    });
-    errors.notFound.labels.forEach(label => {
-      messages.push({ tag: 'label-not-found', value: label });
-    });
-    errors.notFound.users.forEach(user => {
-      messages.push({ tag: 'user-username-not-found', value: user });
-    });
+    if (this.queryErrors) {
+      this.queryErrors.notFound.boards.forEach(board => {
+        messages.push({ tag: 'board-title-not-found', value: board });
+      });
+      this.queryErrors.notFound.swimlanes.forEach(swim => {
+        messages.push({ tag: 'swimlane-title-not-found', value: swim });
+      });
+      this.queryErrors.notFound.lists.forEach(list => {
+        messages.push({ tag: 'list-title-not-found', value: list });
+      });
+      this.queryErrors.notFound.labels.forEach(label => {
+        messages.push({ tag: 'label-not-found', value: label });
+      });
+      this.queryErrors.notFound.users.forEach(user => {
+        messages.push({ tag: 'user-username-not-found', value: user });
+      });
+      this.queryErrors.notFound.members.forEach(user => {
+        messages.push({ tag: 'user-username-not-found', value: user });
+      });
+      this.queryErrors.notFound.assignees.forEach(user => {
+        messages.push({ tag: 'user-username-not-found', value: user });
+      });
+    }
+
+    if (this.parsingErrors.length) {
+      this.parsingErrors.forEach(err => {
+        messages.push(err);
+      });
+    }
 
     return messages;
   },
 
-  resultsHeading() {
-    if (this.resultsCount.get() === 0) {
+  searchAllBoards(query) {
+    this.query.set(query);
+
+    this.resetSearch();
+
+    if (!query) {
+      return;
+    }
+
+    this.searching.set(true);
+
+    // eslint-disable-next-line no-console
+    // console.log('query:', query);
+
+    const reOperator1 = /^((?<operator>\w+):|(?<abbrev>[#@]))(?<value>\w+)(\s+|$)/;
+    const reOperator2 = /^((?<operator>\w+):|(?<abbrev>[#@]))(?<quote>["']*)(?<value>.*?)\k<quote>(\s+|$)/;
+    const reText = /^(?<text>\S+)(\s+|$)/;
+    const reQuotedText = /^(?<quote>["'])(?<text>\w+)\k<quote>(\s+|$)/;
+
+    const operatorMap = {};
+    operatorMap[TAPi18n.__('operator-board')] = 'boards';
+    operatorMap[TAPi18n.__('operator-board-abbrev')] = 'boards';
+    operatorMap[TAPi18n.__('operator-swimlane')] = 'swimlanes';
+    operatorMap[TAPi18n.__('operator-swimlane-abbrev')] = 'swimlanes';
+    operatorMap[TAPi18n.__('operator-list')] = 'lists';
+    operatorMap[TAPi18n.__('operator-list-abbrev')] = 'lists';
+    operatorMap[TAPi18n.__('operator-label')] = 'labels';
+    operatorMap[TAPi18n.__('operator-label-abbrev')] = 'labels';
+    operatorMap[TAPi18n.__('operator-user')] = 'users';
+    operatorMap[TAPi18n.__('operator-user-abbrev')] = 'users';
+    operatorMap[TAPi18n.__('operator-member')] = 'members';
+    operatorMap[TAPi18n.__('operator-member-abbrev')] = 'members';
+    operatorMap[TAPi18n.__('operator-assignee')] = 'assignees';
+    operatorMap[TAPi18n.__('operator-assignee-abbrev')] = 'assignees';
+    operatorMap[TAPi18n.__('operator-is')] = 'is';
+
+    // eslint-disable-next-line no-console
+    // console.log('operatorMap:', operatorMap);
+    const params = {
+      boards: [],
+      swimlanes: [],
+      lists: [],
+      users: [],
+      members: [],
+      assignees: [],
+      labels: [],
+      is: [],
+    };
+
+    let text = '';
+    while (query) {
+      m = query.match(reOperator1);
+      if (!m) {
+        m = query.match(reOperator2);
+        if (m) {
+          query = query.replace(reOperator2, '');
+        }
+      } else {
+        query = query.replace(reOperator1, '');
+      }
+      if (m) {
+        let op;
+        if (m.groups.operator) {
+          op = m.groups.operator.toLowerCase();
+        } else {
+          op = m.groups.abbrev;
+        }
+        if (op in operatorMap) {
+          params[operatorMap[op]].push(m.groups.value);
+        } else {
+          this.parsingErrors.push({
+            tag: 'operator-unknown-error',
+            value: op,
+          });
+        }
+        continue;
+      }
+
+      m = query.match(reQuotedText);
+      if (!m) {
+        m = query.match(reText);
+        if (m) {
+          query = query.replace(reText, '');
+        }
+      } else {
+        query = query.replace(reQuotedText, '');
+      }
+      if (m) {
+        text += (text ? ' ' : '') + m.groups.text;
+      }
+    }
+
+    // eslint-disable-next-line no-console
+    // console.log('text:', text);
+    params.text = text;
+
+    // eslint-disable-next-line no-console
+    // console.log('params:', params);
+
+    this.queryParams = params;
+
+    this.autorun(() => {
+      const handle = subManager.subscribe('globalSearch', params);
+      Tracker.nonreactive(() => {
+        Tracker.autorun(() => {
+          // eslint-disable-next-line no-console
+          // console.log('ready:', handle.ready());
+          if (handle.ready()) {
+            this.searching.set(false);
+            this.hasResults.set(true);
+          }
+        });
+      });
+    });
+  },
+
+  getResultsHeading() {
+    if (this.resultsCount === 0) {
       return TAPi18n.__('no-cards-found');
-    } else if (this.resultsCount.get() === 1) {
+    } else if (this.resultsCount === 1) {
       return TAPi18n.__('one-card-found');
-    } else if (this.resultsCount.get() === this.totalHits.get()) {
-      return TAPi18n.__('n-cards-found', this.resultsCount.get());
+    } else if (this.resultsCount === this.totalHits) {
+      return TAPi18n.__('n-cards-found', this.resultsCount);
     }
 
     return TAPi18n.__('n-n-of-n-cards-found', {
       start: 1,
-      end: this.resultsCount.get(),
-      total: this.totalHits.get(),
+      end: this.resultsCount,
+      total: this.totalHits,
     });
   },
 
@@ -111,6 +267,10 @@ BlazeComponent.extendComponent({
       operator_label_abbrev: TAPi18n.__('operator-label-abbrev'),
       operator_user: TAPi18n.__('operator-user'),
       operator_user_abbrev: TAPi18n.__('operator-user-abbrev'),
+      operator_member: TAPi18n.__('operator-member'),
+      operator_member_abbrev: TAPi18n.__('operator-member-abbrev'),
+      operator_assignee: TAPi18n.__('operator-assignee'),
+      operator_assignee_abbrev: TAPi18n.__('operator-assignee-abbrev'),
     };
 
     text = `# ${TAPi18n.__('globalSearch-instructions-heading')}`;
@@ -141,6 +301,14 @@ BlazeComponent.extendComponent({
       tags,
     )}`;
     text += `\n* ${TAPi18n.__('globalSearch-instructions-operator-at', tags)}`;
+    text += `\n* ${TAPi18n.__(
+      'globalSearch-instructions-operator-member',
+      tags,
+    )}`;
+    text += `\n* ${TAPi18n.__(
+      'globalSearch-instructions-operator-assignee',
+      tags,
+    )}`;
 
     text += `\n## ${TAPi18n.__('heading-notes')}`;
     text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-1', tags)}`;
@@ -157,111 +325,7 @@ BlazeComponent.extendComponent({
       {
         'submit .js-search-query-form'(evt) {
           evt.preventDefault();
-          this.query.set(evt.target.searchQuery.value);
-          this.queryErrors.set(null);
-
-          if (!this.query.get()) {
-            this.searching.set(false);
-            this.hasResults.set(false);
-            return;
-          }
-
-          this.searching.set(true);
-          this.hasResults.set(false);
-
-          let query = this.query.get();
-          // eslint-disable-next-line no-console
-          // console.log('query:', query);
-
-          const reOperator1 = /^((?<operator>\w+):|(?<abbrev>[#@]))(?<value>\w+)(\s+|$)/;
-          const reOperator2 = /^((?<operator>\w+):|(?<abbrev>[#@]))(?<quote>["']*)(?<value>.*?)\k<quote>(\s+|$)/;
-          const reText = /^(?<text>\S+)(\s+|$)/;
-          const reQuotedText = /^(?<quote>["'])(?<text>\w+)\k<quote>(\s+|$)/;
-
-          const operatorMap = {};
-          operatorMap[TAPi18n.__('operator-board')] = 'boards';
-          operatorMap[TAPi18n.__('operator-board-abbrev')] = 'boards';
-          operatorMap[TAPi18n.__('operator-swimlane')] = 'swimlanes';
-          operatorMap[TAPi18n.__('operator-swimlane-abbrev')] = 'swimlanes';
-          operatorMap[TAPi18n.__('operator-list')] = 'lists';
-          operatorMap[TAPi18n.__('operator-list-abbrev')] = 'lists';
-          operatorMap[TAPi18n.__('operator-label')] = 'labels';
-          operatorMap[TAPi18n.__('operator-label-abbrev')] = 'labels';
-          operatorMap[TAPi18n.__('operator-user')] = 'users';
-          operatorMap[TAPi18n.__('operator-user-abbrev')] = 'users';
-          operatorMap[TAPi18n.__('operator-is')] = 'is';
-
-          // eslint-disable-next-line no-console
-          // console.log('operatorMap:', operatorMap);
-          const params = {
-            boards: [],
-            swimlanes: [],
-            lists: [],
-            users: [],
-            labels: [],
-            is: [],
-          };
-
-          let text = '';
-          while (query) {
-            m = query.match(reOperator1);
-            if (!m) {
-              m = query.match(reOperator2);
-              if (m) {
-                query = query.replace(reOperator2, '');
-              }
-            } else {
-              query = query.replace(reOperator1, '');
-            }
-            if (m) {
-              let op;
-              if (m.groups.operator) {
-                op = m.groups.operator.toLowerCase();
-              } else {
-                op = m.groups.abbrev;
-              }
-              if (op in operatorMap) {
-                params[operatorMap[op]].push(m.groups.value);
-              }
-              continue;
-            }
-
-            m = query.match(reQuotedText);
-            if (!m) {
-              m = query.match(reText);
-              if (m) {
-                query = query.replace(reText, '');
-              }
-            } else {
-              query = query.replace(reQuotedText, '');
-            }
-            if (m) {
-              text += (text ? ' ' : '') + m.groups.text;
-            }
-          }
-
-          // eslint-disable-next-line no-console
-          // console.log('text:', text);
-          params.text = text;
-
-          // eslint-disable-next-line no-console
-          // console.log('params:', params);
-
-          this.queryParams = params;
-
-          this.autorun(() => {
-            const handle = subManager.subscribe('globalSearch', params);
-            Tracker.nonreactive(() => {
-              Tracker.autorun(() => {
-                // eslint-disable-next-line no-console
-                // console.log('ready:', handle.ready());
-                if (handle.ready()) {
-                  this.searching.set(false);
-                  this.hasResults.set(true);
-                }
-              });
-            });
-          });
+          this.searchAllBoards(evt.target.searchQuery.value);
         },
       },
     ];

+ 1 - 1
client/components/main/globalSearch.styl

@@ -51,7 +51,7 @@
   margin-top: 0
   margin-bottom: 10px
 
-.global-search-dueat-list-wrapper
+.global-search-results-list-wrapper
   max-width: 500px
   margin-right: auto
   margin-left: auto

+ 4 - 0
config/router.js

@@ -158,7 +158,11 @@ FlowRouter.route('/global-search', {
 
     Utils.manageCustomUI();
     Utils.manageMatomo();
+    DocHead.setTitle(TAPi18n.__('globalSearch-title'));
 
+    // eslint-disable-next-line no-console
+    console.log('URL Params:', FlowRouter.getQueryParam('q'));
+    Session.set('globalQuery', decodeURI(FlowRouter.getQueryParam('q')));
     BlazeLayout.render('defaultLayout', {
       headerBar: 'globalSearchHeaderBar',
       content: 'globalSearch',

+ 7 - 0
i18n/en.i18n.json

@@ -885,7 +885,12 @@
   "operator-label-abbrev": "#",
   "operator-user": "user",
   "operator-user-abbrev": "@",
+  "operator-member": "member",
+  "operator-member-abbrev": "m",
+  "operator-assignee": "assignee",
+  "operator-assignee-abbrev": "a",
   "operator-is": "is",
+  "operator-unknown-error": "%s is not an operator",
   "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\"`).",
@@ -897,6 +902,8 @@
   "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-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.\n`__operator_list__:Available __operator_label__:red` returns only cards in the list *Available* with a *red* label.",

+ 71 - 14
models/cards.js

@@ -1735,16 +1735,31 @@ Cards.globalSearch = queryParams => {
   // eslint-disable-next-line no-console
   // console.log('userId:', userId);
 
-  const errors = {
-    notFound: {
-      boards: [],
-      swimlanes: [],
-      lists: [],
-      labels: [],
-      users: [],
-      is: [],
-    },
-  };
+  const errors = new (class {
+    constructor() {
+      this.notFound = {
+        boards: [],
+        swimlanes: [],
+        lists: [],
+        labels: [],
+        users: [],
+        members: [],
+        assignees: [],
+        is: [],
+      };
+    }
+
+    hasErrors() {
+      for (const prop in this.notFound) {
+        if (this.notFound[prop].length) {
+          // eslint-disable-next-line no-console
+          console.log('errors in:', prop, this.notFound[prop]);
+          return true;
+        }
+      }
+      return false;
+    }
+  })();
 
   const selector = {
     archived: false,
@@ -1808,25 +1823,63 @@ Cards.globalSearch = queryParams => {
     selector.listId.$in = queryLists;
   }
 
+  const queryMembers = [];
+  const queryAssignees = [];
   if (queryParams.users.length) {
-    const queryUsers = [];
     queryParams.users.forEach(query => {
       const users = Users.find({
         username: query,
       });
       if (users.count()) {
         users.forEach(user => {
-          queryUsers.push(user._id);
+          queryMembers.push(user._id);
+          queryAssignees.push(user._id);
         });
       } else {
         errors.notFound.users.push(query);
       }
     });
+  }
 
+  if (queryParams.members.length) {
+    queryParams.members.forEach(query => {
+      const users = Users.find({
+        username: query,
+      });
+      if (users.count()) {
+        users.forEach(user => {
+          queryMembers.push(user._id);
+        });
+      } else {
+        errors.notFound.members.push(query);
+      }
+    });
+  }
+
+  if (queryParams.assignees.length) {
+    queryParams.assignees.forEach(query => {
+      const users = Users.find({
+        username: query,
+      });
+      if (users.count()) {
+        users.forEach(user => {
+          queryAssignees.push(user._id);
+        });
+      } else {
+        errors.notFound.assignees.push(query);
+      }
+    });
+  }
+
+  if (queryMembers.length && queryAssignees.length) {
     selector.$or = [
-      { members: { $in: queryUsers } },
-      { assignees: { $in: queryUsers } },
+      { members: { $in: queryMembers } },
+      { assignees: { $in: queryAssignees } },
     ];
+  } else if (queryMembers.length) {
+    selector.members = { $in: queryMembers };
+  } else if (queryAssignees.length) {
+    selector.assignees = { $in: queryAssignees };
   }
 
   if (queryParams.labels.length) {
@@ -1880,6 +1933,10 @@ Cards.globalSearch = queryParams => {
     });
   }
 
+  if (errors.hasErrors()) {
+    return { cards: null, errors };
+  }
+
   if (queryParams.text) {
     const regex = new RegExp(queryParams.text, 'i');
 

+ 4 - 0
server/publications/cards.js

@@ -181,6 +181,10 @@ Meteor.publish('globalSearch', function(queryParams) {
 
   const cards = Cards.globalSearch(queryParams).cards;
 
+  if (!cards) {
+    return [];
+  }
+
   SessionData.upsert(
     { userId: this.userId },
     {