Merge pull request #3433 from jrsupplee/search

Search
This commit is contained in:
Lauri Ojansivu 2021-01-15 16:50:59 +02:00 committed by GitHub
commit 94cb33a0ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 806 additions and 2 deletions

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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');

View file

@ -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

View file

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

View file

@ -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', { FlowRouter.route('/broken-cards', {
name: 'broken-cards', name: 'broken-cards',
action() { action() {
@ -165,7 +182,6 @@ FlowRouter.route('/broken-cards', {
headerBar: 'brokenCardsHeaderBar', headerBar: 'brokenCardsHeaderBar',
content: brokenCardsTemplate, content: brokenCardsTemplate,
}); });
// }
}, },
}); });

View file

@ -864,5 +864,25 @@
"dueCardsViewChange-choice-me": "Me", "dueCardsViewChange-choice-me": "Me",
"dueCardsViewChange-choice-all": "All Users", "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.", "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"
} }

View file

@ -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) { if (Meteor.isServer) {
Boards.allow({ Boards.allow({
insert: Meteor.userId, insert: Meteor.userId,

View file

@ -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 //FUNCTIONS FOR creation of Activities
function updateActivities(doc, fieldNames, modifier) { function updateActivities(doc, fieldNames, modifier) {

View file

@ -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({ Meteor.methods({
applyWipLimit(listId, limit) { applyWipLimit(listId, limit) {
check(listId, String); check(listId, String);

View file

@ -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 }; Swimlanes.hookOptions.after.update = { fetchPrevious: false };
if (Meteor.isServer) { if (Meteor.isServer) {

View file

@ -311,6 +311,33 @@ Users.attachSchema(
optional: false, optional: false,
defaultValue: 'password', 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,
},
}), }),
); );

View file

@ -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() { Meteor.publish('brokenCards', function() {
const user = Users.findOne(this.userId); const user = Users.findOne(this.userId);
@ -221,11 +261,22 @@ Meteor.publish('brokenCards', function() {
const boards = []; const boards = [];
const swimlanes = []; const swimlanes = [];
const lists = []; const lists = [];
const users = [];
cards.forEach(card => { cards.forEach(card => {
if (card.boardId) boards.push(card.boardId); if (card.boardId) boards.push(card.boardId);
if (card.swimlaneId) swimlanes.push(card.swimlaneId); if (card.swimlaneId) swimlanes.push(card.swimlaneId);
if (card.listId) lists.push(card.listId); 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 [ return [
@ -233,5 +284,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 } }),
]; ];
}); });