瀏覽代碼

Merge pull request #3433 from jrsupplee/search

Search
Lauri Ojansivu 4 年之前
父節點
當前提交
94cb33a0ce

+ 23 - 0
client/components/cards/resultCard.jade

@@ -0,0 +1,23 @@
+template(name="resultCard")
+  .result-card-wrapper
+    a.minicard-wrapper.card-title(href=card.absoluteUrl)
+      +minicard(this)
+      //= card.title
+    ul.result-card-context-list
+      li.result-card-context(title="{{_ 'board'}}")
+        +viewer
+          = getBoard.title
+      li.result-card-context.result-card-context-separator
+        = ' '
+        | {{_ 'context-separator'}}
+        = ' '
+      li.result-card-context(title="{{_ 'swimlane'}}")
+        +viewer
+          = getSwimlane.title
+      li.result-card-context.result-card-context-separator
+        = ' '
+        | {{_ 'context-separator'}}
+        = ' '
+      li.result-card-context(title="{{_ 'list'}}")
+        +viewer
+          = getList.title

+ 11 - 0
client/components/cards/resultCard.js

@@ -0,0 +1,11 @@
+Template.resultCard.helpers({
+  userId() {
+    return Meteor.userId();
+  },
+});
+
+BlazeComponent.extendComponent({
+  events() {
+    return [{}];
+  },
+}).register('resultCard');

+ 21 - 0
client/components/cards/resultCard.styl

@@ -0,0 +1,21 @@
+.result-card-list-wrapper
+  margin: 1rem
+  border-radius: 5px
+  padding: 1.5rem
+  padding-top: 0.75rem
+  display: inline-block
+  min-width: 250px
+  max-width: 350px
+
+.result-card-wrapper
+  margin-top: 0
+  margin-bottom: 10px
+
+.result-card-context
+  display: inline-block
+
+.result-card-context-separator
+  font-weight: bold
+
+.result-card-context-list
+  margin-bottom: 0.7rem

+ 78 - 0
client/components/main/globalSearch.jade

@@ -0,0 +1,78 @@
+template(name="globalSearchHeaderBar")
+  h1
+    i.fa.fa-search
+    | {{_ 'globalSearch-title'}}
+
+template(name="globalSearchModalTitle")
+  h2
+    i.fa.fa-keyboard-o
+    | {{_ 'globalSearch-title'}}
+
+template(name="globalSearch")
+  .wrapper
+    form.global-search-instructions.js-search-query-form
+      input.global-search-query-input(type="text" name="searchQuery" placeholder="{{_ 'search-example'}}" autofocus dir="auto")
+    if searching.get
+      +spinner
+    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
+          div
+            each msg in errorMessages
+              span.global-search-error-messages
+                | {{_ msg.tag msg.value }}
+        each card in results
+          +resultCard(card)
+    else
+      .global-search-instructions
+        h1 Search Operators
+        +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'
+
+template(name="globalSearchViewChangePopup")
+  ul.pop-over-list
+    li
+      with "globalSearchViewChange-choice-me"
+        a.js-global-search-view-me
+          i.fa.fa-user.colorful
+          | {{_ 'globalSearchViewChange-choice-me'}}
+          if $eq Utils.globalSearchView "me"
+            i.fa.fa-check
+    li
+      with "globalSearchViewChange-choice-all"
+        a.js-global-search-view-all
+          i.fa.fa-users.colorful
+          | {{_ 'globalSearchViewChange-choice-all'}}
+          span.sub-name
+            +viewer
+              | {{_ 'globalSearchViewChange-choice-all-description' }}
+          if $eq Utils.globalSearchView "all"
+            i.fa.fa-check

+ 201 - 0
client/components/main/globalSearch.js

@@ -0,0 +1,201 @@
+const subManager = new SubsManager();
+
+BlazeComponent.extendComponent({
+  events() {
+    return [
+      {
+        'click .js-due-cards-view-change': Popup.open('globalSearchViewChange'),
+      },
+    ];
+  },
+}).register('globalSearchHeaderBar');
+
+Template.globalSearch.helpers({
+  userId() {
+    return Meteor.userId();
+  },
+});
+
+BlazeComponent.extendComponent({
+  events() {
+    return [
+      {
+        'click .js-due-cards-view-me'() {
+          Utils.setDueCardsView('me');
+          Popup.close();
+        },
+
+        'click .js-due-cards-view-all'() {
+          Utils.setDueCardsView('all');
+          Popup.close();
+        },
+      },
+    ];
+  },
+}).register('globalSearchViewChangePopup');
+
+BlazeComponent.extendComponent({
+  onCreated() {
+    this.isPageReady = new ReactiveVar(true);
+    this.searching = new ReactiveVar(false);
+    this.hasResults = new ReactiveVar(false);
+    this.query = new ReactiveVar('');
+    this.queryParams = null;
+    this.resultsCount = new ReactiveVar(0);
+    this.totalHits = new ReactiveVar(0);
+    this.queryErrors = new ReactiveVar(null);
+    Meteor.subscribe('setting');
+  },
+
+  results() {
+    if (this.queryParams) {
+      const results = Cards.globalSearch(this.queryParams);
+      // 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;
+    }
+    this.resultsCount.set(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.users.forEach(user => {
+      messages.push({ tag: 'user-username-not-found', value: user });
+    });
+
+    return messages;
+  },
+
+  events() {
+    return [
+      {
+        '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);
+                }
+              });
+            });
+          });
+        },
+      },
+    ];
+  },
+}).register('globalSearch');

+ 97 - 0
client/components/main/globalSearch.styl

@@ -0,0 +1,97 @@
+.global-search-board-wrapper
+  border-radius: 8px
+  //padding: 0.5rem
+  min-width: 400px
+  border-width: 8px
+  border-color: grey
+  border-style: solid
+  margin-bottom: 2rem
+  margin-right: auto
+  margin-left: auto
+
+.global-search-board-title
+  font-size: 1.4rem
+  font-weight: bold
+  padding: 0.5rem
+  background-color: grey
+  color: white
+
+.global-search-swimlane-title
+  font-size: 1.1rem
+  font-weight: bold
+  padding: 0.5rem
+  padding-bottom: 0.4rem
+  margin-top: 0
+  margin-bottom: 0.5rem
+  //border-top: black 1px solid
+  //border-bottom: black 1px solid
+  text-align: center
+
+.swimlane-default-color
+  background-color: lightgrey
+
+.global-search-list-title
+  font-weight: bold
+  font-size: 1.1rem
+  //padding-bottom: 0
+  //margin-bottom: 0
+  text-align: center
+  margin-bottom: 0.7rem
+
+.global-search-list-wrapper
+  margin: 1rem
+  border-radius: 5px
+  padding: 1.5rem
+  padding-top: 0.75rem
+  display: inline-block
+  min-width: 250px
+  max-width: 350px
+
+.global-search-card-wrapper
+  margin-top: 0
+  margin-bottom: 10px
+
+.global-search-dueat-list-wrapper
+  max-width: 500px
+  margin-right: auto
+  margin-left: auto
+
+.global-search-field-name
+  font-weight: bold
+
+.global-search-context
+  display: inline-block
+
+.global-search-context-separator
+  font-weight: bold
+
+.global-search-context-list
+  margin-bottom: 0.7rem
+
+.global-search-error-messages
+  color: darkred
+
+.global-search-instructions
+  width: 40%
+  min-width: 400px
+  margin-right: auto
+  margin-left: auto
+  line-height: 150%
+
+.global-search-query-input
+  width: 90% !important
+  margin-right: auto
+  margin-left: auto
+
+.global-search-operator
+  font-family: Courier
+
+.global-search-value
+  font-family: Courier
+  font-style: italic
+
+code
+  color: white
+  background-color: grey
+  padding: 0.1rem !important
+  font-size: 0.7rem !important

+ 4 - 0
client/components/users/userHeader.jade

@@ -21,6 +21,10 @@ template(name="memberMenuPopup")
         a.js-due-cards(href="{{pathFor 'due-cards'}}")
           i.fa.fa-calendar
           | {{_ 'dueCards-title'}}
+      li
+        a.js-global-search(href="{{pathFor 'global-search'}}")
+          i.fa.fa-search
+          | {{_ 'globalSearch-title'}}
       li
         a.js-broken-cards(href="{{pathFor 'broken-cards'}}")
           i.fa.fa-chain-broken

+ 17 - 1
config/router.js

@@ -149,6 +149,23 @@ FlowRouter.route('/due-cards', {
   },
 });
 
+FlowRouter.route('/global-search', {
+  name: 'global-search',
+  action() {
+    Filter.reset();
+    // EscapeActions.executeAll();
+    EscapeActions.executeUpTo('popup-close');
+
+    Utils.manageCustomUI();
+    Utils.manageMatomo();
+
+    BlazeLayout.render('defaultLayout', {
+      headerBar: 'globalSearchHeaderBar',
+      content: 'globalSearch',
+    });
+  },
+});
+
 FlowRouter.route('/broken-cards', {
   name: 'broken-cards',
   action() {
@@ -165,7 +182,6 @@ FlowRouter.route('/broken-cards', {
       headerBar: 'brokenCardsHeaderBar',
       content: brokenCardsTemplate,
     });
-    // }
   },
 });
 

+ 21 - 1
i18n/en.i18n.json

@@ -864,5 +864,25 @@
   "dueCardsViewChange-choice-me": "Me",
   "dueCardsViewChange-choice-all": "All Users",
   "dueCardsViewChange-choice-all-description": "Shows all incomplete cards with a *Due* date from boards for which the user has permission.",
-  "broken-cards": "Broken Cards"
+  "broken-cards": "Broken Cards",
+  "board-title-not-found": "Board '%s' not found.",
+  "swimlane-title-not-found": "Swimlane '%s' not found.",
+  "list-title-not-found": "List '%s' not found.",
+  "user-username-not-found": "Username '%s' not found.",
+  "globalSearch-title": "Search All Boards",
+  "no-cards-found": "No Cards Found",
+  "one-card-found": "One Card Found",
+  "n-cards-found": "%s Cards Found",
+  "n-n-of-n-cards-found": "%s-%s of %s Cards Found",
+  "operator-board": "board",
+  "operator-board-abbrev": "b",
+  "operator-swimlane": "swimlane",
+  "operator-swimlane-abbrev": "s",
+  "operator-list": "list",
+  "operator-list-abbrev": "l",
+  "operator-label": "label",
+  "operator-label-abbrev": "#",
+  "operator-user": "user",
+  "operator-user-abbrev": "@",
+  "operator-is": "is"
 }

+ 39 - 0
models/boards.js

@@ -1208,6 +1208,45 @@ function boardRemover(userId, doc) {
   );
 }
 
+Boards.userSearch = (
+  userId,
+  selector = {},
+  projection = {},
+  includeArchived = false,
+) => {
+  if (!includeArchived) {
+    selector.archived = false;
+  }
+  selector.$or = [
+    { permission: 'public' },
+    { members: { $elemMatch: { userId, isActive: true } } },
+  ];
+
+  return Boards.find(selector, projection);
+};
+
+Boards.userBoards = (userId, includeArchived = false, selector = {}) => {
+  check(userId, String);
+
+  if (!includeArchived) {
+    selector = {
+      archived: false,
+    };
+  }
+  selector.$or = [
+    { permission: 'public' },
+    { members: { $elemMatch: { userId, isActive: true } } },
+  ];
+
+  return Boards.find(selector);
+};
+
+Boards.userBoardIds = (userId, includeArchived = false, selector = {}) => {
+  return Boards.userBoards(userId, includeArchived, selector).map(board => {
+    return board._id;
+  });
+};
+
 if (Meteor.isServer) {
   Boards.allow({
     insert: Meteor.userId,

+ 195 - 0
models/cards.js

@@ -1730,6 +1730,201 @@ Cards.mutations({
   },
 });
 
+Cards.globalSearch = queryParams => {
+  const userId = Meteor.userId();
+  // eslint-disable-next-line no-console
+  // console.log('userId:', userId);
+
+  const errors = {
+    notFound: {
+      boards: [],
+      swimlanes: [],
+      lists: [],
+      labels: [],
+      users: [],
+      is: [],
+    },
+  };
+
+  const selector = {
+    archived: false,
+    type: 'cardType-card',
+    boardId: { $in: Boards.userBoardIds(userId) },
+    swimlaneId: { $nin: Swimlanes.archivedSwimlaneIds() },
+    listId: { $nin: Lists.archivedListIds() },
+  };
+
+  if (queryParams.boards.length) {
+    const queryBoards = [];
+    queryParams.boards.forEach(query => {
+      const boards = Boards.userSearch(userId, {
+        title: new RegExp(query, 'i'),
+      });
+      if (boards.count()) {
+        boards.forEach(board => {
+          queryBoards.push(board._id);
+        });
+      } else {
+        errors.notFound.boards.push(query);
+      }
+    });
+
+    selector.boardId.$in = queryBoards;
+  }
+
+  if (queryParams.swimlanes.length) {
+    const querySwimlanes = [];
+    queryParams.swimlanes.forEach(query => {
+      const swimlanes = Swimlanes.find({
+        title: new RegExp(query, 'i'),
+      });
+      if (swimlanes.count()) {
+        swimlanes.forEach(swim => {
+          querySwimlanes.push(swim._id);
+        });
+      } else {
+        errors.notFound.swimlanes.push(query);
+      }
+    });
+
+    selector.swimlaneId.$in = querySwimlanes;
+  }
+
+  if (queryParams.lists.length) {
+    const queryLists = [];
+    queryParams.lists.forEach(query => {
+      const lists = Lists.find({
+        title: new RegExp(query, 'i'),
+      });
+      if (lists.count()) {
+        lists.forEach(list => {
+          queryLists.push(list._id);
+        });
+      } else {
+        errors.notFound.lists.push(query);
+      }
+    });
+
+    selector.listId.$in = queryLists;
+  }
+
+  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);
+        });
+      } else {
+        errors.notFound.users.push(query);
+      }
+    });
+
+    selector.$or = [
+      { members: { $in: queryUsers } },
+      { assignees: { $in: queryUsers } },
+    ];
+  }
+
+  if (queryParams.labels.length) {
+    queryParams.labels.forEach(label => {
+      const queryLabels = [];
+
+      let boards = Boards.userSearch(userId, {
+        labels: { $elemMatch: { color: label.toLowerCase() } },
+      });
+
+      if (boards.count()) {
+        boards.forEach(board => {
+          // eslint-disable-next-line no-console
+          // console.log('board:', board);
+          // eslint-disable-next-line no-console
+          // console.log('board.labels:', board.labels);
+          board.labels
+            .filter(boardLabel => {
+              return boardLabel.color === label.toLowerCase();
+            })
+            .forEach(boardLabel => {
+              queryLabels.push(boardLabel._id);
+            });
+        });
+      } else {
+        // eslint-disable-next-line no-console
+        // console.log('label:', label);
+        const reLabel = new RegExp(label, 'i');
+        // eslint-disable-next-line no-console
+        // console.log('reLabel:', reLabel);
+        boards = Boards.userSearch(userId, {
+          labels: { $elemMatch: { name: reLabel } },
+        });
+
+        if (boards.count()) {
+          boards.forEach(board => {
+            board.labels
+              .filter(boardLabel => {
+                return boardLabel.name.match(reLabel);
+              })
+              .forEach(boardLabel => {
+                queryLabels.push(boardLabel._id);
+              });
+          });
+        } else {
+          errors.notFound.labels.push({ tag: 'label', value: label });
+        }
+      }
+
+      selector.labelIds = { $in: queryLabels };
+    });
+  }
+
+  if (queryParams.text) {
+    const regex = new RegExp(queryParams.text, 'i');
+
+    selector.$or = [
+      { title: regex },
+      { description: regex },
+      { customFields: { $elemMatch: { value: regex } } },
+    ];
+  }
+
+  // eslint-disable-next-line no-console
+  // console.log('selector:', selector);
+  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,
+      labelIds: 1,
+    },
+    limit: 50,
+  });
+
+  // eslint-disable-next-line no-console
+  // 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 };
+};
+
 //FUNCTIONS FOR creation of Activities
 
 function updateActivities(doc, fieldNames, modifier) {

+ 10 - 0
models/lists.js

@@ -328,6 +328,16 @@ Lists.mutations({
   },
 });
 
+Lists.archivedLists = () => {
+  return Lists.find({ archived: true });
+};
+
+Lists.archivedListIds = () => {
+  return Lists.archivedLists().map(list => {
+    return list._id;
+  });
+};
+
 Meteor.methods({
   applyWipLimit(listId, limit) {
     check(listId, String);

+ 10 - 0
models/swimlanes.js

@@ -283,6 +283,16 @@ Swimlanes.mutations({
   },
 });
 
+Swimlanes.archivedSwimlanes = () => {
+  return Swimlanes.find({ archived: true });
+};
+
+Swimlanes.archivedSwimlaneIds = () => {
+  return Swimlanes.archivedSwimlanes().map(swim => {
+    return swim._id;
+  });
+};
+
 Swimlanes.hookOptions.after.update = { fetchPrevious: false };
 
 if (Meteor.isServer) {

+ 27 - 0
models/users.js

@@ -311,6 +311,33 @@ Users.attachSchema(
       optional: false,
       defaultValue: 'password',
     },
+    sessionData: {
+      /**
+       * profile settings
+       */
+      type: Object,
+      optional: true,
+      // eslint-disable-next-line consistent-return
+      autoValue() {
+        if (this.isInsert && !this.isSet) {
+          return {};
+        }
+      },
+    },
+    'sessionData.totalHits': {
+      /**
+       * Total hits from last search
+       */
+      type: Number,
+      optional: true,
+    },
+    'sessionData.lastHit': {
+      /**
+       * last hit that was returned
+       */
+      type: Number,
+      optional: true,
+    },
   }),
 );
 

+ 52 - 0
server/publications/cards.js

@@ -175,6 +175,46 @@ Meteor.publish('dueCards', function(allUsers = false) {
   ];
 });
 
+Meteor.publish('globalSearch', function(queryParams) {
+  check(queryParams, Object);
+
+  // eslint-disable-next-line no-console
+  // console.log('queryParams:', queryParams);
+
+  const cards = Cards.globalSearch(queryParams).cards;
+
+  const boards = [];
+  const swimlanes = [];
+  const lists = [];
+  const users = [this.userId];
+
+  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);
+      });
+    }
+  });
+
+  // eslint-disable-next-line no-console
+  // console.log('users:', users);
+  return [
+    cards,
+    Boards.find({ _id: { $in: boards } }),
+    Swimlanes.find({ _id: { $in: swimlanes } }),
+    Lists.find({ _id: { $in: lists } }),
+    Users.find({ _id: { $in: users } }),
+  ];
+});
+
 Meteor.publish('brokenCards', function() {
   const user = Users.findOne(this.userId);
 
@@ -221,11 +261,22 @@ Meteor.publish('brokenCards', function() {
   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 [
@@ -233,5 +284,6 @@ Meteor.publish('brokenCards', function() {
     Boards.find({ _id: { $in: boards } }),
     Swimlanes.find({ _id: { $in: swimlanes } }),
     Lists.find({ _id: { $in: lists } }),
+    Users.find({ _id: { $in: users } }),
   ];
 });