diff --git a/client/components/cards/resultCard.jade b/client/components/cards/resultCard.jade new file mode 100644 index 000000000..b1fabd9b6 --- /dev/null +++ b/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 diff --git a/client/components/cards/resultCard.js b/client/components/cards/resultCard.js new file mode 100644 index 000000000..3b5da12cf --- /dev/null +++ b/client/components/cards/resultCard.js @@ -0,0 +1,11 @@ +Template.resultCard.helpers({ + userId() { + return Meteor.userId(); + }, +}); + +BlazeComponent.extendComponent({ + events() { + return [{}]; + }, +}).register('resultCard'); diff --git a/client/components/cards/resultCard.styl b/client/components/cards/resultCard.styl new file mode 100644 index 000000000..def39a4d3 --- /dev/null +++ b/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 diff --git a/client/components/main/globalSearch.jade b/client/components/main/globalSearch.jade new file mode 100644 index 000000000..f2d2a2c7c --- /dev/null +++ b/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 diff --git a/client/components/main/globalSearch.js b/client/components/main/globalSearch.js new file mode 100644 index 000000000..560cf9dda --- /dev/null +++ b/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 = /^((?\w+):|(?[#@]))(?\w+)(\s+|$)/; + const reOperator2 = /^((?\w+):|(?[#@]))(?["']*)(?.*?)\k(\s+|$)/; + const reText = /^(?\S+)(\s+|$)/; + const reQuotedText = /^(?["'])(?\w+)\k(\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'); diff --git a/client/components/main/globalSearch.styl b/client/components/main/globalSearch.styl new file mode 100644 index 000000000..20bb45138 --- /dev/null +++ b/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 diff --git a/client/components/users/userHeader.jade b/client/components/users/userHeader.jade index 24d486eb4..d3ef6e3ff 100644 --- a/client/components/users/userHeader.jade +++ b/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 diff --git a/config/router.js b/config/router.js index 9e360371a..4d5a386f9 100644 --- a/config/router.js +++ b/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, }); - // } }, }); diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 665feea9c..c4a22427e 100644 --- a/i18n/en.i18n.json +++ b/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" } diff --git a/models/boards.js b/models/boards.js index a8b3967c9..cfeda7309 100644 --- a/models/boards.js +++ b/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, diff --git a/models/cards.js b/models/cards.js index 92e80931a..04852cb9a 100644 --- a/models/cards.js +++ b/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) { diff --git a/models/lists.js b/models/lists.js index 0fc2a24bc..dcfd4294e 100644 --- a/models/lists.js +++ b/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); diff --git a/models/swimlanes.js b/models/swimlanes.js index 2e200454a..0994e9044 100644 --- a/models/swimlanes.js +++ b/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) { diff --git a/models/users.js b/models/users.js index 7dd011e04..8b0c13230 100644 --- a/models/users.js +++ b/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, + }, }), ); diff --git a/server/publications/cards.js b/server/publications/cards.js index ae775c031..a1fef2696 100644 --- a/server/publications/cards.js +++ b/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 } }), ]; });