Переглянути джерело

Merge pull request #3437 from jrsupplee/search

Global Search fixes and updates
Lauri Ojansivu 4 роки тому
батько
коміт
7f0da49e0a

+ 1 - 1
client/components/cards/resultCard.jade

@@ -1,6 +1,6 @@
 template(name="resultCard")
 template(name="resultCard")
   .result-card-wrapper
   .result-card-wrapper
-    a.minicard-wrapper.card-title(href=card.absoluteUrl)
+    a.minicard-wrapper.card-title(href=absoluteUrl)
       +minicard(this)
       +minicard(this)
       //= card.title
       //= card.title
     ul.result-card-context-list
     ul.result-card-context-list

+ 9 - 35
client/components/main/globalSearch.jade

@@ -14,52 +14,26 @@ template(name="globalSearch")
   if currentUser
   if currentUser
     .wrapper
     .wrapper
       form.global-search-instructions.js-search-query-form
       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
       if searching.get
         +spinner
         +spinner
       else if hasResults.get
       else if hasResults.get
-        .global-search-dueat-list-wrapper
-          h1
-            if $eq resultsCount.get 0
-              | {{_ 'no-cards-found' }}
-            else if $eq resultsCount.get 1
-              | {{_ 'one-card-found' }}
-            else if $eq resultsCount.get totalHits.get
-              | {{_ 'n-cards-found' resultsCount.get }}
-            else
-              | {{_ 'n-n-of-n-cards-found' 1 resultsCount.get totalHits.get }}
-          if queryErrors.get
+        .global-search-results-list-wrapper
+          if hasQueryErrors.get
             div
             div
               each msg in errorMessages
               each msg in errorMessages
                 span.global-search-error-messages
                 span.global-search-error-messages
                   | {{_ msg.tag msg.value }}
                   | {{_ msg.tag msg.value }}
-          each card in results
-            a.minicard-wrapper(href=card.absoluteUrl)
+          else
+            h1
+              = resultsHeading.get
+              a.fa.fa-link(title="{{_ 'link-to-search' }}" href="{{ getSearchHref }}")
+            each card in results
               +resultCard(card)
               +resultCard(card)
       else
       else
         .global-search-instructions
         .global-search-instructions
-          h1 Search Operators
           +viewer
           +viewer
-            = '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. `list:"To Review"`).\n'
-            = 'Available operators are:\n'
-            = '* `board:title` - cards in boards matching the specified title\n'
-            = '* `list:title` - cards in lists matching the specified title\n'
-            = '* `swimlane:title` - cards in swimlanes matching the specified title\n'
-            = '* `label:color` - cards that have a label matching the given color\n'
-            = '* `label:name` - cards that have a label matching the given name\n'
-            = '* `user:username` - cards where the specified user is a member or assignee\n'
-            = '* `@username` - shorthand for `user:username`\n'
-            = '* `#label` - shorthand for `label:color-or-name`\n'
-            = '## Notes\n'
-            = '*  Multiple operators may be specified.\n'
-            = '*  Similar operators are *OR*ed together.  Cards that match any of the conditions will be returned.\n'
-            = '    `list:Available list:Blocked` would return cards contained in any list named *Blocked* or *Available*.\n'
-            = '*  Differing operators are *AND*ed together.  Only cards that match all of the differing operators are returned.\n'
-            = '`list:Available label:red` returns only cards in the list *Available* with a *red* label.\n'
-            = '* Text searches are case insensitive.\n'
+            = searchInstructions
 
 
 template(name="globalSearchViewChangePopup")
 template(name="globalSearchViewChangePopup")
   if currentUser
   if currentUser

+ 264 - 128
client/components/main/globalSearch.js

@@ -36,164 +36,300 @@ BlazeComponent.extendComponent({
 
 
 BlazeComponent.extendComponent({
 BlazeComponent.extendComponent({
   onCreated() {
   onCreated() {
-    this.isPageReady = new ReactiveVar(true);
     this.searching = new ReactiveVar(false);
     this.searching = new ReactiveVar(false);
     this.hasResults = new ReactiveVar(false);
     this.hasResults = new ReactiveVar(false);
+    this.hasQueryErrors = new ReactiveVar(false);
     this.query = new ReactiveVar('');
     this.query = new ReactiveVar('');
+    this.resultsHeading = new ReactiveVar('');
+    this.searchLink = new ReactiveVar(null);
     this.queryParams = null;
     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');
     Meteor.subscribe('setting');
+    if (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() {
   results() {
+    // eslint-disable-next-line no-console
+    // console.log('getting results');
     if (this.queryParams) {
     if (this.queryParams) {
       const results = Cards.globalSearch(this.queryParams);
       const results = Cards.globalSearch(this.queryParams);
+      this.queryErrors = results.errors;
       // eslint-disable-next-line no-console
       // eslint-disable-next-line no-console
-      // console.log('user:', Meteor.user());
-      // eslint-disable-next-line no-console
-      // console.log('user:', Meteor.user().sessionData);
-      // console.log('errors:', results.errors);
-      this.totalHits.set(Meteor.user().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 [];
     return [];
   },
   },
 
 
   errorMessages() {
   errorMessages() {
-    const errors = this.queryErrors.get();
     const messages = [];
     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.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;
     return messages;
   },
   },
 
 
-  events() {
-    return [
-      {
-        'submit .js-search-query-form'(evt) {
-          evt.preventDefault();
-          this.query.set(evt.target.searchQuery.value);
-          this.queryErrors.set(null);
+  searchAllBoards(query) {
+    this.query.set(query);
 
 
-          if (!this.query.get()) {
-            this.searching.set(false);
-            this.hasResults.set(false);
-            return;
-          }
+    this.resetSearch();
 
 
-          this.searching.set(true);
-          this.hasResults.set(false);
+    if (!query) {
+      return;
+    }
 
 
-          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';
+    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
           // 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;
-            }
+          // console.log('ready:', handle.ready());
+          if (handle.ready()) {
+            this.searching.set(false);
+            this.hasResults.set(true);
           }
           }
+        });
+      });
+    });
+  },
 
 
-          // eslint-disable-next-line no-console
-          // console.log('text:', text);
-          params.text = text;
+  getResultsHeading() {
+    if (this.resultsCount === 0) {
+      return TAPi18n.__('no-cards-found');
+    } else if (this.resultsCount === 1) {
+      return TAPi18n.__('one-card-found');
+    } else if (this.resultsCount === this.totalHits) {
+      return TAPi18n.__('n-cards-found', this.resultsCount);
+    }
 
 
-          // 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);
-                }
-              });
-            });
-          });
+    return TAPi18n.__('n-n-of-n-cards-found', {
+      start: 1,
+      end: this.resultsCount,
+      total: this.totalHits,
+    });
+  },
+
+  getSearchHref() {
+    const baseUrl = window.location.href.replace(/([?#].*$|\s*$)/, '');
+    return `${baseUrl}?q=${encodeURIComponent(this.query.get())}`;
+  },
+
+  searchInstructions() {
+    tags = {
+      operator_board: TAPi18n.__('operator-board'),
+      operator_list: TAPi18n.__('operator-list'),
+      operator_swimlane: TAPi18n.__('operator-swimlane'),
+      operator_label: TAPi18n.__('operator-label'),
+      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')}`;
+    text += `\n${TAPi18n.__('globalSearch-instructions-description', tags)}`;
+    text += `\n${TAPi18n.__('globalSearch-instructions-operators', tags)}`;
+    text += `\n* ${TAPi18n.__(
+      '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-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-member',
+      tags,
+    )}`;
+    text += `\n* ${TAPi18n.__(
+      'globalSearch-instructions-operator-assignee',
+      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-4', tags)}`;
+    text += `\n* ${TAPi18n.__('globalSearch-instructions-notes-5', tags)}`;
+
+    return text;
+  },
+
+  events() {
+    return [
+      {
+        'submit .js-search-query-form'(evt) {
+          evt.preventDefault();
+          this.searchAllBoards(evt.target.searchQuery.value);
         },
         },
       },
       },
     ];
     ];

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

@@ -51,7 +51,7 @@
   margin-top: 0
   margin-top: 0
   margin-bottom: 10px
   margin-bottom: 10px
 
 
-.global-search-dueat-list-wrapper
+.global-search-results-list-wrapper
   max-width: 500px
   max-width: 500px
   margin-right: auto
   margin-right: auto
   margin-left: auto
   margin-left: auto
@@ -91,7 +91,7 @@
   font-style: italic
   font-style: italic
 
 
 code
 code
-  color: white
-  background-color: grey
+  color: black
+  background-color: lightgrey
   padding: 0.1rem !important
   padding: 0.1rem !important
   font-size: 0.7rem !important
   font-size: 0.7rem !important

+ 7 - 0
config/router.js

@@ -158,7 +158,14 @@ FlowRouter.route('/global-search', {
 
 
     Utils.manageCustomUI();
     Utils.manageCustomUI();
     Utils.manageMatomo();
     Utils.manageMatomo();
+    DocHead.setTitle(TAPi18n.__('globalSearch-title'));
 
 
+    if (FlowRouter.getQueryParam('q')) {
+      Session.set(
+        'globalQuery',
+        decodeURIComponent(FlowRouter.getQueryParam('q')),
+      );
+    }
     BlazeLayout.render('defaultLayout', {
     BlazeLayout.render('defaultLayout', {
       headerBar: 'globalSearchHeaderBar',
       headerBar: 'globalSearchHeaderBar',
       content: 'globalSearch',
       content: 'globalSearch',

+ 27 - 2
i18n/en.i18n.json

@@ -868,12 +868,13 @@
   "board-title-not-found": "Board '%s' not found.",
   "board-title-not-found": "Board '%s' not found.",
   "swimlane-title-not-found": "Swimlane '%s' not found.",
   "swimlane-title-not-found": "Swimlane '%s' not found.",
   "list-title-not-found": "List '%s' not found.",
   "list-title-not-found": "List '%s' not found.",
+  "label-not-found": "Label '%s' not found.",
   "user-username-not-found": "Username '%s' not found.",
   "user-username-not-found": "Username '%s' not found.",
   "globalSearch-title": "Search All Boards",
   "globalSearch-title": "Search All Boards",
   "no-cards-found": "No Cards Found",
   "no-cards-found": "No Cards Found",
   "one-card-found": "One Card Found",
   "one-card-found": "One Card Found",
   "n-cards-found": "%s Cards Found",
   "n-cards-found": "%s Cards Found",
-  "n-n-of-n-cards-found": "%s-%s of %s Cards Found",
+  "n-n-of-n-cards-found": "__start__-__end__ of __total__ Cards Found",
   "operator-board": "board",
   "operator-board": "board",
   "operator-board-abbrev": "b",
   "operator-board-abbrev": "b",
   "operator-swimlane": "swimlane",
   "operator-swimlane": "swimlane",
@@ -884,5 +885,29 @@
   "operator-label-abbrev": "#",
   "operator-label-abbrev": "#",
   "operator-user": "user",
   "operator-user": "user",
   "operator-user-abbrev": "@",
   "operator-user-abbrev": "@",
-  "operator-is": "is"
+  "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\"`).",
+  "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-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-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.",
+  "globalSearch-instructions-notes-4": "Text searches are case insensitive.",
+  "globalSearch-instructions-notes-5": "Currently archived cards are not searched.",
+  "link-to-search": "Link to this search"
 }
 }

+ 70 - 23
models/cards.js

@@ -1866,16 +1866,29 @@ Cards.globalSearch = queryParams => {
   // eslint-disable-next-line no-console
   // eslint-disable-next-line no-console
   // console.log('userId:', userId);
   // 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) {
+          return true;
+        }
+      }
+      return false;
+    }
+  })();
 
 
   const selector = {
   const selector = {
     archived: false,
     archived: false,
@@ -1939,25 +1952,63 @@ Cards.globalSearch = queryParams => {
     selector.listId.$in = queryLists;
     selector.listId.$in = queryLists;
   }
   }
 
 
+  const queryMembers = [];
+  const queryAssignees = [];
   if (queryParams.users.length) {
   if (queryParams.users.length) {
-    const queryUsers = [];
     queryParams.users.forEach(query => {
     queryParams.users.forEach(query => {
       const users = Users.find({
       const users = Users.find({
         username: query,
         username: query,
       });
       });
       if (users.count()) {
       if (users.count()) {
         users.forEach(user => {
         users.forEach(user => {
-          queryUsers.push(user._id);
+          queryMembers.push(user._id);
+          queryAssignees.push(user._id);
         });
         });
       } else {
       } else {
         errors.notFound.users.push(query);
         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 = [
     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) {
   if (queryParams.labels.length) {
@@ -2003,7 +2054,7 @@ Cards.globalSearch = queryParams => {
               });
               });
           });
           });
         } else {
         } else {
-          errors.notFound.labels.push({ tag: 'label', value: label });
+          errors.notFound.labels.push(label);
         }
         }
       }
       }
 
 
@@ -2011,6 +2062,10 @@ Cards.globalSearch = queryParams => {
     });
     });
   }
   }
 
 
+  if (errors.hasErrors()) {
+    return { cards: null, errors };
+  }
+
   if (queryParams.text) {
   if (queryParams.text) {
     const regex = new RegExp(queryParams.text, 'i');
     const regex = new RegExp(queryParams.text, 'i');
 
 
@@ -2045,14 +2100,6 @@ Cards.globalSearch = queryParams => {
   // eslint-disable-next-line no-console
   // eslint-disable-next-line no-console
   // console.log('count:', cards.count());
   // console.log('count:', cards.count());
 
 
-  if (Meteor.isServer) {
-    Users.update(userId, {
-      $set: {
-        'sessionData.totalHits': cards.count(),
-        'sessionData.lastHit': cards.count() > 50 ? 50 : cards.count(),
-      },
-    });
-  }
   return { cards, errors };
   return { cards, errors };
 };
 };
 
 

+ 8 - 0
models/users.js

@@ -377,6 +377,14 @@ Users.initEasySearch(searchInFields, {
   returnFields: [...searchInFields, 'profile.avatarUrl'],
   returnFields: [...searchInFields, 'profile.avatarUrl'],
 });
 });
 
 
+Users.safeFields = {
+  _id: 1,
+  username: 1,
+  'profile.fullname': 1,
+  'profile.avatarUrl': 1,
+  'profile.initials': 1,
+};
+
 if (Meteor.isClient) {
 if (Meteor.isClient) {
   Users.helpers({
   Users.helpers({
     isBoardMember() {
     isBoardMember() {

+ 73 - 0
models/usersessiondata.js

@@ -0,0 +1,73 @@
+SessionData = new Mongo.Collection('sessiondata');
+
+/**
+ * A UserSessionData in Wekan. Organization in Trello.
+ */
+SessionData.attachSchema(
+  new SimpleSchema({
+    _id: {
+      /**
+       * the organization id
+       */
+      type: Number,
+      optional: true,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert && !this.isSet) {
+          return incrementCounter('counters', 'orgId', 1);
+        }
+      },
+    },
+    userId: {
+      /**
+       * userId of the user
+       */
+      type: String,
+      optional: false,
+    },
+    totalHits: {
+      /**
+       * total number of hits in the last report query
+       */
+      type: Number,
+      optional: true,
+    },
+    lastHit: {
+      /**
+       * the last hit returned from a report query
+       */
+      type: Number,
+      optional: true,
+    },
+    createdAt: {
+      /**
+       * creation date of the team
+       */
+      type: Date,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert) {
+          return new Date();
+        } else if (this.isUpsert) {
+          return { $setOnInsert: new Date() };
+        } else {
+          this.unset();
+        }
+      },
+    },
+    modifiedAt: {
+      type: Date,
+      denyUpdate: false,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert || this.isUpsert || this.isUpdate) {
+          return new Date();
+        } else {
+          this.unset();
+        }
+      },
+    },
+  }),
+);
+
+export default SessionData;

+ 24 - 69
server/publications/cards.js

@@ -72,18 +72,7 @@ Meteor.publish('myCards', function() {
     Boards.find({ _id: { $in: boards } }),
     Boards.find({ _id: { $in: boards } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Lists.find({ _id: { $in: lists } }),
     Lists.find({ _id: { $in: lists } }),
-    Users.find(
-      { _id: { $in: users } },
-      {
-        fields: {
-          _id: 1,
-          username: 1,
-          'profile.fullname': 1,
-          'profile.avatarUrl': 1,
-          'profile.initials': 1,
-        },
-      },
-    ),
+    Users.find({ _id: { $in: users } }, { fields: Users.safeFields }),
   ];
   ];
 });
 });
 
 
@@ -93,18 +82,7 @@ Meteor.publish('dueCards', function(allUsers = false) {
   // eslint-disable-next-line no-console
   // eslint-disable-next-line no-console
   // console.log('all users:', allUsers);
   // console.log('all users:', allUsers);
 
 
-  const user = Users.findOne(
-    { _id: this.userId },
-    {
-      fields: {
-        _id: 1,
-        username: 1,
-        'profile.fullname': 1,
-        'profile.avatarUrl': 1,
-        'profile.initials': 1,
-      },
-    },
-  );
+  const user = Users.findOne({ _id: this.userId });
 
 
   const archivedBoards = [];
   const archivedBoards = [];
   Boards.find({ archived: true }).forEach(board => {
   Boards.find({ archived: true }).forEach(board => {
@@ -115,14 +93,12 @@ Meteor.publish('dueCards', function(allUsers = false) {
   let selector = {
   let selector = {
     archived: false,
     archived: false,
   };
   };
-  // for admins and users, allow her to see cards only from boards where
-  // she is a member
-  //if (!user.isAdmin) {
+
   selector.$or = [
   selector.$or = [
     { permission: 'public' },
     { permission: 'public' },
     { members: { $elemMatch: { userId: user._id, isActive: true } } },
     { members: { $elemMatch: { userId: user._id, isActive: true } } },
   ];
   ];
-  //}
+
   Boards.find(selector).forEach(board => {
   Boards.find(selector).forEach(board => {
     permiitedBoards.push(board._id);
     permiitedBoards.push(board._id);
   });
   });
@@ -193,18 +169,7 @@ Meteor.publish('dueCards', function(allUsers = false) {
     Boards.find({ _id: { $in: boards } }),
     Boards.find({ _id: { $in: boards } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Lists.find({ _id: { $in: lists } }),
     Lists.find({ _id: { $in: lists } }),
-    Users.find(
-      { _id: { $in: users } },
-      {
-        fields: {
-          _id: 1,
-          username: 1,
-          'profile.fullname': 1,
-          'profile.avatarUrl': 1,
-          'profile.initials': 1,
-        },
-      },
-    ),
+    Users.find({ _id: { $in: users } }, { fields: Users.safeFields }),
   ];
   ];
 });
 });
 
 
@@ -216,6 +181,20 @@ Meteor.publish('globalSearch', function(queryParams) {
 
 
   const cards = Cards.globalSearch(queryParams).cards;
   const cards = Cards.globalSearch(queryParams).cards;
 
 
+  if (!cards) {
+    return [];
+  }
+
+  SessionData.upsert(
+    { userId: this.userId },
+    {
+      $set: {
+        totalHits: cards.count(),
+        lastHit: cards.count() > 50 ? 50 : cards.count(),
+      },
+    },
+  );
+
   const boards = [];
   const boards = [];
   const swimlanes = [];
   const swimlanes = [];
   const lists = [];
   const lists = [];
@@ -244,34 +223,21 @@ Meteor.publish('globalSearch', function(queryParams) {
     Boards.find({ _id: { $in: boards } }),
     Boards.find({ _id: { $in: boards } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Lists.find({ _id: { $in: lists } }),
     Lists.find({ _id: { $in: lists } }),
-    Users.find({ _id: { $in: users } }),
+    Users.find({ _id: { $in: users } }, { fields: Users.safeFields }),
+    SessionData.find({ userId: this.userId }),
   ];
   ];
 });
 });
 
 
 Meteor.publish('brokenCards', function() {
 Meteor.publish('brokenCards', function() {
-  const user = Users.findOne(
-    { _id: this.userId },
-    {
-      fields: {
-        _id: 1,
-        username: 1,
-        'profile.fullname': 1,
-        'profile.avatarUrl': 1,
-        'profile.initials': 1,
-      },
-    },
-  );
+  const user = Users.findOne({ _id: this.userId });
 
 
   const permiitedBoards = [null];
   const permiitedBoards = [null];
   let selector = {};
   let selector = {};
-  // for admins and users, if user is not an admin allow her to see cards only from boards where
-  // she is a member
-  //if (!user.isAdmin) {
   selector.$or = [
   selector.$or = [
     { permission: 'public' },
     { permission: 'public' },
     { members: { $elemMatch: { userId: user._id, isActive: true } } },
     { members: { $elemMatch: { userId: user._id, isActive: true } } },
   ];
   ];
-  //}
+
   Boards.find(selector).forEach(board => {
   Boards.find(selector).forEach(board => {
     permiitedBoards.push(board._id);
     permiitedBoards.push(board._id);
   });
   });
@@ -328,17 +294,6 @@ Meteor.publish('brokenCards', function() {
     Boards.find({ _id: { $in: boards } }),
     Boards.find({ _id: { $in: boards } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Lists.find({ _id: { $in: lists } }),
     Lists.find({ _id: { $in: lists } }),
-    Users.find(
-      { _id: { $in: users } },
-      {
-        fields: {
-          _id: 1,
-          username: 1,
-          'profile.fullname': 1,
-          'profile.avatarUrl': 1,
-          'profile.initials': 1,
-        },
-      },
-    ),
+    Users.find({ _id: { $in: users } }, { fields: Users.safeFields }),
   ];
   ];
 });
 });