From 4b71fd253b84aef4b71400a6df3d753d47156400 Mon Sep 17 00:00:00 2001 From: cpradio Date: Mon, 26 Sep 2016 20:01:40 -0400 Subject: [PATCH 1/3] Advanced Search UI Properly support Categories so it updates the search box correctly Use category id, as it is more consistent with search results than using the slugs, especially for parent/subcategory Added Status Improve AutoComplete so it can receive updates Added the ability for AutoComplete to receive updates to badge-selector and group-selector Respect null, which is set via web-hooks Support both # and category: for category detection. Only update the searchedTerms if they differ from its current value (this helps the Category Selector receive updates) Opt in receive updates (#3) * Make the selectors opt-in for receiving updates * Opt-in to receive updates * Fix category detection for search-advanced-options Fix eslint error Update user-selector so it can receive updates live too Make the canReceiveUpdates check validate against 'true' Converted to use template literals Refactor the regex involved with this feature Split apart the init to make it a bit more manageable/testable Switch the category selector to category-chooser, so it is a dropdown of categories instead of auto-complete Reduce RegEx to make this happier with unicode languages and reduce some of the complexity --- .../components/badge-selector.js.es6 | 48 +++ .../components/category-selector.js.es6 | 20 +- .../components/group-selector.js.es6 | 15 +- .../components/search-advanced-options.js.es6 | 400 ++++++++++++++++++ .../discourse/components/user-selector.js.es6 | 8 +- .../discourse/lib/autocomplete.js.es6 | 29 +- .../javascripts/discourse/models/badge.js.es6 | 2 +- .../badge-selector-autocomplete.raw.hbs | 7 + .../templates/components/badge-selector.hbs | 1 + .../components/search-advanced-options.hbs | 78 ++++ .../discourse/templates/full-page-search.hbs | 2 + .../stylesheets/common/base/search.scss | 18 + app/controllers/badges_controller.rb | 5 + config/locales/client.en.yml | 40 ++ 14 files changed, 659 insertions(+), 14 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/badge-selector.js.es6 create mode 100644 app/assets/javascripts/discourse/components/search-advanced-options.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/badge-selector.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs diff --git a/app/assets/javascripts/discourse/components/badge-selector.js.es6 b/app/assets/javascripts/discourse/components/badge-selector.js.es6 new file mode 100644 index 00000000000..14b1e5349ee --- /dev/null +++ b/app/assets/javascripts/discourse/components/badge-selector.js.es6 @@ -0,0 +1,48 @@ +import { on, observes, default as computed } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + @computed('placeholderKey') + placeholder(placeholderKey) { + return placeholderKey ? I18n.t(placeholderKey) : ''; + }, + + @observes('badgeNames') + _update() { + if (this.get('canReceiveUpdates') === 'true') + this._initializeAutocomplete({updateData: true}); + }, + + @on('didInsertElement') + _initializeAutocomplete(opts) { + var self = this; + var selectedBadges; + + var template = this.container.lookup('template:badge-selector-autocomplete.raw'); + self.$('input').autocomplete({ + allowAny: false, + items: _.isArray(this.get('badgeNames')) ? this.get('badgeNames') : [this.get('badgeNames')], + single: this.get('single'), + updateData: (opts && opts.updateData) ? opts.updateData : false, + onChangeItems: function(items){ + selectedBadges = items; + self.set("badgeNames", items.join(",")); + }, + transformComplete: function(g) { + return g.name; + }, + dataSource: function(term) { + return self.get("badgeFinder")(term).then(function(badges){ + + if(!selectedBadges){ + return badges; + } + + return badges.filter(function(badge){ + return !selectedBadges.any(function(s){return s === badge.name;}); + }); + }); + }, + template: template + }); + } +}); diff --git a/app/assets/javascripts/discourse/components/category-selector.js.es6 b/app/assets/javascripts/discourse/components/category-selector.js.es6 index eb060406246..10a7c454ebd 100644 --- a/app/assets/javascripts/discourse/components/category-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/category-selector.js.es6 @@ -1,18 +1,25 @@ import { categoryBadgeHTML } from 'discourse/helpers/category-link'; import Category from 'discourse/models/category'; -import { on } from 'ember-addons/ember-computed-decorators'; +import { on, observes } from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ + @observes('categories') + _update() { + if (this.get('canReceiveUpdates') === 'true') + this._initializeAutocomplete({updateData: true}); + }, + @on('didInsertElement') - _initializeAutocomplete() { + _initializeAutocomplete(opts) { const self = this, template = this.container.lookup('template:category-selector-autocomplete.raw'), regexp = new RegExp(`href=['\"]${Discourse.getURL('/c/')}([^'\"]+)`); this.$('input').autocomplete({ items: this.get('categories'), - single: false, + single: this.get('single'), allowAny: false, + updateData: (opts && opts.updateData) ? opts.updateData : false, dataSource(term) { return Category.list().filter(category => { const regex = new RegExp(term, 'i'); @@ -26,7 +33,12 @@ export default Ember.Component.extend({ const slug = link.match(regexp)[1]; return Category.findSingleBySlug(slug); }); - Em.run.next(() => self.set('categories', categories)); + Em.run.next(() => { + let existingCategory = _.isArray(self.get('categories')) ? self.get('categories') : [self.get('categories')]; + const result = _.intersection(existingCategory.map(itm => itm.id), categories.map(itm => itm.id)); + if (result.length !== categories.length || existingCategory.length !== categories.length) + self.set('categories', categories); + }); }, template, transformComplete(category) { diff --git a/app/assets/javascripts/discourse/components/group-selector.js.es6 b/app/assets/javascripts/discourse/components/group-selector.js.es6 index 6b278e7a836..a2080ecda23 100644 --- a/app/assets/javascripts/discourse/components/group-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/group-selector.js.es6 @@ -1,4 +1,4 @@ -import { on, default as computed } from 'ember-addons/ember-computed-decorators'; +import { on, observes, default as computed } from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ @computed('placeholderKey') @@ -6,15 +6,24 @@ export default Ember.Component.extend({ return placeholderKey ? I18n.t(placeholderKey) : ''; }, + @observes('groupNames') + _update() { + if (this.get('canReceiveUpdates') === 'true') + this._initializeAutocomplete({updateData: true}); + }, + @on('didInsertElement') - _initializeAutocomplete() { + _initializeAutocomplete(opts) { var self = this; var selectedGroups; + var groupNames = this.get('groupNames'); var template = this.container.lookup('template:group-selector-autocomplete.raw'); self.$('input').autocomplete({ allowAny: false, - items: this.get('groupNames'), + items: _.isArray(groupNames) ? groupNames : (Ember.isEmpty(groupNames)) ? [] : [groupNames], + single: this.get('single'), + updateData: (opts && opts.updateData) ? opts.updateData : false, onChangeItems: function(items){ selectedGroups = items; self.set("groupNames", items.join(",")); diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 new file mode 100644 index 00000000000..72c37455a41 --- /dev/null +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -0,0 +1,400 @@ +import { on, observes, default as computed } from 'ember-addons/ember-computed-decorators'; + +const REGEXP_FILTER_PREFIXES = /(user:|@|category:|#|group:|badge:|tags?:|in:|status:|posts_count:|(before|after):)/ig; + +const REGEXP_USERNAME_PREFIX = /\s?(user:|@)/ig; +const REGEXP_CATEGORY_PREFIX = /\s?(category:|#)/ig; +const REGEXP_GROUP_PREFIX = /\s?group:/ig; +const REGEXP_BADGE_PREFIX = /\s?badge:/ig; +const REGEXP_TAGS_PREFIX = /\s?tags?:/ig; +const REGEXP_IN_PREFIX = /\s?in:/ig; +const REGEXP_STATUS_PREFIX = /\s?status:/ig; +const REGEXP_POST_COUNT_PREFIX = /\s?posts_count:/ig; +const REGEXP_POST_TIME_PREFIX = /\s?(before|after):/ig; + +const REGEXP_CATEGORY_SLUG = /\s?(\#[a-zA-Z0-9\-:]+)/ig; +const REGEXP_CATEGORY_ID = /\s?(category:[0-9]+)/ig; +const REGEXP_POST_TIME_WHEN = /(before|after)/ig; + +export default Em.Component.extend({ + tagName: 'div', + classNames: ['search-advanced', 'row'], + searchedTerms: {username: [], category: null, group: [], badge: [], tags: [], + in: '', status: '', posts_count: '', time: {when: 'before', days: ''}}, + inOptions: [ + {name: I18n.t('search.advanced.filters.likes'), value: "likes"}, + {name: I18n.t('search.advanced.filters.posted'), value: "posted"}, + {name: I18n.t('search.advanced.filters.watching'), value: "watching"}, + {name: I18n.t('search.advanced.filters.tracking'), value: "tracking"}, + {name: I18n.t('search.advanced.filters.private'), value: "private"}, + {name: I18n.t('search.advanced.filters.bookmarks'), value: "bookmarks"}, + {name: I18n.t('search.advanced.filters.first'), value: "first"}, + {name: I18n.t('search.advanced.filters.pinned'), value: "pinned"}, + {name: I18n.t('search.advanced.filters.unpinned'), value: "unpinned"}, + {name: I18n.t('search.advanced.filters.wiki'), value: "wiki"} + ], + statusOptions: [ + {name: I18n.t('search.advanced.statuses.open'), value: "open"}, + {name: I18n.t('search.advanced.statuses.closed'), value: "closed"}, + {name: I18n.t('search.advanced.statuses.archived'), value: "archived"}, + {name: I18n.t('search.advanced.statuses.noreplies'), value: "noreplies"}, + {name: I18n.t('search.advanced.statuses.single_user'), value: "single_user"}, + ], + postTimeOptions: [ + {name: I18n.t('search.advanced.post.time.before'), value: "before"}, + {name: I18n.t('search.advanced.post.time.after'), value: "after"} + ], + + @on('init') + @observes('searchTerm') + _init() { + let searchTerm = this.get('searchTerm'); + + if (!searchTerm) + return; + + this.findUsername(searchTerm); + this.findCategory(searchTerm); + this.findGroup(searchTerm); + this.findBadge(searchTerm); + this.findTags(searchTerm); + this.findIn(searchTerm); + this.findStatus(searchTerm); + this.findPostsCount(searchTerm); + this.findPostTime(searchTerm); + }, + + findSearchTerm(EXPRESSION, searchTerm) { + const expression_location = searchTerm.search(EXPRESSION); + if (expression_location === -1) + return ""; + + const remaining_phrase = searchTerm.substring(expression_location + 2); + let next_expression_location = remaining_phrase.search(REGEXP_FILTER_PREFIXES); + if (next_expression_location === -1) + next_expression_location = remaining_phrase.length; + + return searchTerm.substring(expression_location, next_expression_location + expression_location + 2); + }, + + findUsername(searchTerm) { + const match = this.findSearchTerm(REGEXP_USERNAME_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = _.isArray(this.get('searchedTerms.username')) ? this.get('searchedTerms.username')[0] : this.get('searchedTerms.username'); + let userInput = match.replace(REGEXP_USERNAME_PREFIX, ''); + if (userInput.length !== 0 && existingInput !== userInput) + this.set('searchedTerms.username', [userInput]); + } else + this.set('searchedTerms.username', []); + }, + + @observes('searchedTerms.username') + updateUsername() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_USERNAME_PREFIX, searchTerm); + const userFilter = this.get('searchedTerms.username'); + if (userFilter && userFilter.length !== 0) + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` user:${userFilter}`); + else + searchTerm += ` user:${userFilter}`; + else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + findCategory(searchTerm) { + const match = this.findSearchTerm(REGEXP_CATEGORY_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = _.isArray(this.get('searchedTerms.category')) ? this.get('searchedTerms.category')[0] : this.get('searchedTerms.category'); + const subcategories = match.replace(REGEXP_CATEGORY_PREFIX, '').split(':'); + if (subcategories.length > 1) { + let userInput = Discourse.Category.findBySlug(subcategories[1], subcategories[0]); + if ((!existingInput && userInput) + || (existingInput && userInput && existingInput.id !== userInput.id)) + this.set('searchedTerms.category', userInput.id); + } else + if (isNaN(subcategories)) { + let userInput = Discourse.Category.findSingleBySlug(subcategories[0]); + if ((!existingInput && userInput) + || (existingInput && userInput && existingInput.id !== userInput.id)) + this.set('searchedTerms.category', userInput.id); + } else { + let userInput = Discourse.Category.findById(subcategories[0]); + if ((!existingInput && userInput) + || (existingInput && userInput && existingInput.id !== userInput.id)) + this.set('searchedTerms.category', userInput.id); + } + } else + this.set('searchedTerms.category', null); + }, + + @observes('searchedTerms.category') + updateCategory() { + let searchTerm = this.get('searchTerm'); + const categoryFilter = Discourse.Category.findById(this.get('searchedTerms.category')); + + const match = this.findSearchTerm(REGEXP_CATEGORY_PREFIX, searchTerm); + const slugCategoryMatches = match.match(REGEXP_CATEGORY_SLUG); + const idCategoryMatches = match.match(REGEXP_CATEGORY_ID); + if (categoryFilter && categoryFilter.length !== 0) { + const id = categoryFilter.id; + const slug = categoryFilter.slug; + if (categoryFilter && categoryFilter.parentCategory) { + const parentSlug = categoryFilter.parentCategory.slug; + if (slugCategoryMatches) + searchTerm = searchTerm.replace(slugCategoryMatches[0], ` #${parentSlug}:${slug}`); + else if (idCategoryMatches) + searchTerm = searchTerm.replace(idCategoryMatches[0], ` category:${id}`); + else + searchTerm += ` #${parentSlug}:${slug}`; + } else if (categoryFilter) { + if (slugCategoryMatches) + searchTerm = searchTerm.replace(slugCategoryMatches[0], ` #${slug}`); + else if (idCategoryMatches) + searchTerm = searchTerm.replace(idCategoryMatches[0], ` category:${id}`); + else + searchTerm += ` #${slug}`; + } + } else { + if (slugCategoryMatches) + searchTerm = searchTerm.replace(slugCategoryMatches[0], ''); + if (idCategoryMatches) + searchTerm = searchTerm.replace(idCategoryMatches[0], ''); + } + + this.set('searchTerm', searchTerm); + }, + + findGroup(searchTerm) { + const match = this.findSearchTerm(REGEXP_GROUP_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = _.isArray(this.get('searchedTerms.group')) ? this.get('searchedTerms.group')[0] : this.get('searchedTerms.group'); + let userInput = match.replace(REGEXP_GROUP_PREFIX, ''); + if (userInput.length !== 0 && existingInput !== userInput) + this.set('searchedTerms.group', [userInput]); + } else + this.set('searchedTerms.group', []); + }, + + @observes('searchedTerms.group') + updateGroup() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_GROUP_PREFIX, searchTerm); + const groupFilter = this.get('searchedTerms.group'); + if (groupFilter && groupFilter.length !== 0) + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` group:${groupFilter}`); + else + searchTerm += ` group:${groupFilter}`; + else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + findBadge(searchTerm) { + const match = this.findSearchTerm(REGEXP_BADGE_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = _.isArray(this.get('searchedTerms.badge')) ? this.get('searchedTerms.badge')[0] : this.get('searchedTerms.badge'); + let userInput = match.replace(REGEXP_BADGE_PREFIX, ''); + if (userInput.length !== 0 && existingInput !== userInput) + this.set('searchedTerms.badge', [match.replace(REGEXP_BADGE_PREFIX, '')]); + } else + this.set('searchedTerms.badge', []); + }, + + @observes('searchedTerms.badge') + updateBadge() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_BADGE_PREFIX, searchTerm); + const badgeFilter = this.get('searchedTerms.badge'); + if (badgeFilter && badgeFilter.length !== 0) + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` badge:${badgeFilter}`); + else + searchTerm += ` badge:${badgeFilter}`; + else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + findTags(searchTerm) { + const match = this.findSearchTerm(REGEXP_TAGS_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = _.isArray(this.get('searchedTerms.tags')) ? this.get('searchedTerms.tags').join(',') : this.get('searchedTerms.tags'); + let userInput = match.replace(REGEXP_TAGS_PREFIX, ''); + if (userInput.length !== 0 && existingInput !== userInput) + this.set('searchedTerms.tags', userInput.split(',')); + } else + this.set('searchedTerms.tags', []); + }, + + @observes('searchedTerms.tags') + updateTags() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_TAGS_PREFIX, searchTerm); + const tagFilter = this.get('searchedTerms.tags'); + if (tagFilter && tagFilter.length !== 0) { + const tags = tagFilter.join(','); + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` tags:${tags}`); + else + searchTerm += ` tags:${tags}`; + } else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + findIn(searchTerm) { + const match = this.findSearchTerm(REGEXP_IN_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = this.get('searchedTerms.in'); + let userInput = match.replace(REGEXP_IN_PREFIX, ''); + if (userInput.length !== 0 && existingInput !== userInput) + this.set('searchedTerms.in', userInput); + } else + this.set('searchedTerms.in', ''); + }, + + @observes('searchedTerms.in') + updateIn() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_IN_PREFIX, searchTerm); + const inFilter = this.get('searchedTerms.in'); + if (inFilter) + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` in:${inFilter}`); + else + searchTerm += ` in:${inFilter}`; + else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + findStatus(searchTerm) { + const match = this.findSearchTerm(REGEXP_STATUS_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = this.get('searchedTerms.status'); + let userInput = match.replace(REGEXP_STATUS_PREFIX, ''); + if (userInput.length !== 0 && existingInput !== userInput) + this.set('searchedTerms.status', userInput); + } else + this.set('searchedTerms.status', ''); + }, + + @observes('searchedTerms.status') + updateStatus() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_STATUS_PREFIX, searchTerm); + const statusFilter = this.get('searchedTerms.status'); + if (statusFilter) + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` status:${statusFilter}`); + else + searchTerm += ` status:${statusFilter}`; + else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + findPostsCount(searchTerm) { + const match = this.findSearchTerm(REGEXP_POST_COUNT_PREFIX, searchTerm); + if (match.length !== 0) { + let existingInput = this.get('searchedTerms.posts_count'); + let userInput = match.replace(REGEXP_POST_COUNT_PREFIX, ''); + if (userInput.length !== 0 && existingInput !== userInput) + this.set('searchedTerms.posts_count', userInput); + } else + this.set('searchedTerms.posts_count', ''); + }, + + @observes('searchedTerms.posts_count') + updatePostsCount() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_POST_COUNT_PREFIX, searchTerm); + const postsCountFilter = this.get('searchedTerms.posts_count'); + if (postsCountFilter) + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` posts_count:${postsCountFilter}`); + else + searchTerm += ` posts_count:${postsCountFilter}`; + else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + findPostTime(searchTerm) { + const match = this.findSearchTerm(REGEXP_POST_TIME_WHEN, searchTerm); + if (match.length !== 0) { + let existingInputWhen = this.get('searchedTerms.time.when'); + let userInputWhen = match.match(REGEXP_POST_TIME_WHEN)[0]; + if (userInputWhen.length !== 0 && existingInputWhen !== userInputWhen) + this.set('searchedTerms.time.when', userInputWhen); + + let existingInputDays = this.get('searchedTerms.time.days'); + let userInputDays = match.replace(REGEXP_POST_TIME_PREFIX, ''); + if (userInputDays.length !== 0 && existingInputDays !== userInputDays) + this.set('searchedTerms.time.days', userInputDays); + } else + this.set('searchedTerms.time.days', ''); + }, + + @observes('searchedTerms.time.when', 'searchedTerms.time.days') + updatePostTime() { + let searchTerm = this.get('searchTerm'); + + const match = this.findSearchTerm(REGEXP_POST_TIME_PREFIX, searchTerm); + const timeDaysFilter = this.get('searchedTerms.time.days'); + if (timeDaysFilter) { + const when = this.get('searchedTerms.time.when'); + if (match.length !== 0) + searchTerm = searchTerm.replace(match, ` ${when}:${timeDaysFilter}`); + else + searchTerm += ` ${when}:${timeDaysFilter}`; + } else if (match.length !== 0) + searchTerm = searchTerm.replace(match, ''); + + this.set('searchTerm', searchTerm); + }, + + groupFinder(term) { + const Group = require('discourse/models/group').default; + return Group.findAll({search: term, ignore_automatic: false}); + }, + + badgeFinder(term) { + const Badge = require('discourse/models/badge').default; + return Badge.findAll({search: term}); + }, + + collapsedClassName: function() { + return (this.get('isExpanded')) ? "fa-caret-down" : "fa-caret-right"; + }.property('isExpanded'), + + @computed('isExpanded') + isCollapsed(isExpanded){ + return !isExpanded; + }, + + actions: { + expandOptions() { + this.set('isExpanded', !this.get('isExpanded')); + if (this.get('isExpanded')) + this._init(); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index 862f0fae491..e124b43fb2f 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -3,8 +3,13 @@ import TextField from 'discourse/components/text-field'; import userSearch from 'discourse/lib/user-search'; export default TextField.extend({ + @observes('usernames') + _update() { + if (this.get('canReceiveUpdates') === 'true') + this.didInsertElement({updateData: true}); + }, - didInsertElement() { + didInsertElement(opts) { this._super(); var self = this, selected = [], @@ -29,6 +34,7 @@ export default TextField.extend({ disabled: this.get('disabled'), single: this.get('single'), allowAny: this.get('allowAny'), + updateData: (opts && opts.updateData) ? opts.updateData : false, dataSource: function(term) { var results = userSearch({ diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 index 9509b4622ad..47f8b0ff3c8 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 +++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 @@ -42,7 +42,7 @@ export default function(options) { if (this.length === 0) return; - if (options === 'destroy') { + if (options === 'destroy' || options.updateData) { Ember.run.cancel(inputTimeout); $(this).off('keyup.autocomplete') @@ -50,7 +50,8 @@ export default function(options) { .off('paste.autocomplete') .off('click.autocomplete'); - return; + if (options === 'destroy') + return; } if (options && options.cancel && this.data("closeAutocomplete")) { @@ -162,28 +163,46 @@ export default function(options) { if (isInput) { const width = this.width(); - wrap = this.wrap("
").parent(); - wrap.width(width); + + if (options.updateData) { + wrap = this.parent(); + wrap.find('.item').remove(); + me.show(); + } else { + wrap = this.wrap("
").parent(); + wrap.width(width); + } + if(options.single) { this.css("width","100%"); } else { this.width(150); } - this.attr('name', this.attr('name') + "-renamed"); + + this.attr('name', (options.updateData) ? this.attr('name') : this.attr('name') + "-renamed"); + var vals = this.val().split(","); _.each(vals,function(x) { if (x !== "") { if (options.reverseTransform) { x = options.reverseTransform(x); } + if(options.single){ + me.hide(); + } addInputSelectedItem(x); } }); + if(options.items) { _.each(options.items, function(item){ + if(options.single){ + me.hide(); + } addInputSelectedItem(item); }); } + this.val(""); completeStart = 0; wrap.click(function() { diff --git a/app/assets/javascripts/discourse/models/badge.js.es6 b/app/assets/javascripts/discourse/models/badge.js.es6 index 333b40c57ff..f94b6576696 100644 --- a/app/assets/javascripts/discourse/models/badge.js.es6 +++ b/app/assets/javascripts/discourse/models/badge.js.es6 @@ -135,7 +135,7 @@ Badge.reopenClass({ if(opts && opts.onlyListable){ listable = "?only_listable=true"; } - return ajax('/badges.json' + listable).then(function(badgesJson) { + return ajax('/badges.json' + listable, { data: opts }).then(function(badgesJson) { return Badge.createFromJson(badgesJson); }); }, diff --git a/app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs b/app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs new file mode 100644 index 00000000000..4537c451601 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs @@ -0,0 +1,7 @@ +
+ +
diff --git a/app/assets/javascripts/discourse/templates/components/badge-selector.hbs b/app/assets/javascripts/discourse/templates/components/badge-selector.hbs new file mode 100644 index 00000000000..c1c29859e26 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/badge-selector.hbs @@ -0,0 +1 @@ + diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs new file mode 100644 index 00000000000..f71b0f08d29 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -0,0 +1,78 @@ +

+ {{i18n "search.advanced.title"}} +

+{{#unless isCollapsed}} +
+
+
+ +
+ {{user-selector excludeCurrentUser=false usernames=searchedTerms.username class="user-selector" single="true" canReceiveUpdates="true"}} +
+
+
+ +
+ {{category-chooser value=searchedTerms.category}} +
+
+
+ +
+
+ +
+ {{group-selector groupFinder=groupFinder groupNames=searchedTerms.group single="true" canReceiveUpdates="true"}} +
+
+
+ +
+ {{badge-selector badgeFinder=badgeFinder badgeNames=searchedTerms.badge single="true" canReceiveUpdates="true"}} +
+
+
+ + {{#if siteSettings.tagging_enabled}} +
+
+ +
+ {{tag-chooser tags=searchedTerms.tags blacklist=searchedTerms.tags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true"}} +
+
+
+ {{/if}} + +
+
+ +
+ {{combo-box valueAttribute="value" content=inOptions value=searchedTerms.in none="user.locale.any"}} +
+
+
+ +
+ {{combo-box valueAttribute="value" content=statusOptions value=searchedTerms.status none="user.locale.any"}} +
+
+
+ +
+
+ +
+ {{combo-box valueAttribute="value" content=postTimeOptions value=searchedTerms.time.when}} + {{input type="text" value=searchedTerms.time.days class="input-small" id='search-post-date'}} +
+
+
+ +
+ {{input type="number" value=searchedTerms.posts_count class="input-small" id='search-posts-count'}} +
+
+
+
+{{/unless}} diff --git a/app/assets/javascripts/discourse/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs index f2d3445865f..31df545ec72 100644 --- a/app/assets/javascripts/discourse/templates/full-page-search.hbs +++ b/app/assets/javascripts/discourse/templates/full-page-search.hbs @@ -2,6 +2,8 @@ {{search-text-field value=searchTerm class="full-page-search input-xxlarge search no-blur" action="search" hasAutofocus=hasAutofocus}} {{d-button action="search" icon="search" class="btn-primary" disabled=searching}} + {{search-advanced-options searchTerm=searchTerm}} + {{#if canCreateTopic}} {{d-button id="create-topic" class="btn-default" action="createTopic" actionParam=searchTerm icon="plus" label="topic.create"}} {{/if}} diff --git a/app/assets/stylesheets/common/base/search.scss b/app/assets/stylesheets/common/base/search.scss index 835a17bf812..2fe1a4d8fb2 100644 --- a/app/assets/stylesheets/common/base/search.scss +++ b/app/assets/stylesheets/common/base/search.scss @@ -97,6 +97,24 @@ } } +.search-advanced { + margin-bottom: 15px; + + .panel-title { + background-color: dark-light-diff($primary, $secondary, 90%, -75%); + padding: 5px 10px 5px 10px; + } + + .search-options { + border: 1px solid dark-light-diff($primary, $secondary, 90%, -75%); + padding: 10px; + + .control-group.pull-left { + width: 50%; + } + } +} + .search-footer { margin-bottom: 30px; } diff --git a/app/controllers/badges_controller.rb b/app/controllers/badges_controller.rb index 4b15a7796e6..e7991c9de9a 100644 --- a/app/controllers/badges_controller.rb +++ b/app/controllers/badges_controller.rb @@ -6,6 +6,11 @@ class BadgesController < ApplicationController badges = Badge.all + if search = params[:search] + search = search.to_s + badges = badges.where("name ILIKE ?", "%#{search}%") + end + if (params[:only_listable] == "true") || !request.xhr? # NOTE: this is sorted client side if needed badges = badges.includes(:badge_grouping) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index cb25065a0fd..ffa0cd2d723 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -674,6 +674,7 @@ en: title: "Interface language" instructions: "User interface language. It will change when you refresh the page." default: "(default)" + any: "any" password_confirmation: title: "Password Again" @@ -1217,6 +1218,45 @@ en: topic: "Search this topic" private_messages: "Search messages" + advanced: + title: Advanced Search + posted_by: + label: Posted by + in_category: + label: In Category + in_group: + label: In Group + with_badge: + label: With Badge + with_tags: + label: With Tags + filters: + label: Only return topics/posts that... + likes: I liked + posted: I posted in + watching: I'm watching + tracking: I'm tracking + private: are in my messages + bookmarks: I've bookmarked + first: are the very first post + pinned: are pinned + unpinned: are not pinned + wiki: are wiki + statuses: + label: Where topics + open: are open + closed: are closed + archived: are archived + noreplies: have zero replies + single_user: contain a single user + post: + count: + label: Minimum Post Count + time: + label: Posted + before: before + after: after + hamburger_menu: "go to another topic list or category" new_item: "new" go_back: 'go back' From a7507dad63953488c6f2acbf2ecc853454cc06b1 Mon Sep 17 00:00:00 2001 From: cpradio Date: Thu, 6 Oct 2016 15:18:05 -0400 Subject: [PATCH 2/3] Add Acceptance Tests (which found an error) Tests for populating category and tags Added acceptance tests for populating the Username and Category through the Advanced Search UI (will work on the other fields later) Give the combo-box elements ids Add acceptance tests for the rest of the advanced search UI (except for Tags, tags are evil) --- .../components/search-advanced-options.js.es6 | 5 +- .../components/search-advanced-options.hbs | 6 +- .../acceptance/search-full-test.js.es6 | 231 +++++++++++++++++- test/javascripts/helpers/qunit-helpers.js.es6 | 12 +- 4 files changed, 245 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 index 72c37455a41..6fabacccc87 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -1,6 +1,6 @@ import { on, observes, default as computed } from 'ember-addons/ember-computed-decorators'; -const REGEXP_FILTER_PREFIXES = /(user:|@|category:|#|group:|badge:|tags?:|in:|status:|posts_count:|(before|after):)/ig; +const REGEXP_FILTER_PREFIXES = /\s?(user:|@|category:|#|group:|badge:|tags?:|in:|status:|posts_count:|(before|after):)/ig; const REGEXP_USERNAME_PREFIX = /\s?(user:|@)/ig; const REGEXP_CATEGORY_PREFIX = /\s?(category:|#)/ig; @@ -65,6 +65,9 @@ export default Em.Component.extend({ }, findSearchTerm(EXPRESSION, searchTerm) { + if (!searchTerm) + return ""; + const expression_location = searchTerm.search(EXPRESSION); if (expression_location === -1) return ""; diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs index f71b0f08d29..fb6997ee627 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -48,13 +48,13 @@
- {{combo-box valueAttribute="value" content=inOptions value=searchedTerms.in none="user.locale.any"}} + {{combo-box id="in" valueAttribute="value" content=inOptions value=searchedTerms.in none="user.locale.any"}}
- {{combo-box valueAttribute="value" content=statusOptions value=searchedTerms.status none="user.locale.any"}} + {{combo-box id="status" valueAttribute="value" content=statusOptions value=searchedTerms.status none="user.locale.any"}}
@@ -63,7 +63,7 @@
- {{combo-box valueAttribute="value" content=postTimeOptions value=searchedTerms.time.when}} + {{combo-box id="post-time" valueAttribute="value" content=postTimeOptions value=searchedTerms.time.when}} {{input type="text" value=searchedTerms.time.days class="input-small" id='search-post-date'}}
diff --git a/test/javascripts/acceptance/search-full-test.js.es6 b/test/javascripts/acceptance/search-full-test.js.es6 index 889b066ee60..248efe47454 100644 --- a/test/javascripts/acceptance/search-full-test.js.es6 +++ b/test/javascripts/acceptance/search-full-test.js.es6 @@ -1,5 +1,43 @@ -import { acceptance } from "helpers/qunit-helpers"; -acceptance("Search - Full Page"); +import { acceptance, waitFor } from "helpers/qunit-helpers"; +acceptance("Search - Full Page", { + settings: {tagging_enabled: true}, + setup() { + const response = (object) => { + return [ + 200, + {"Content-Type": "application/json"}, + object + ]; + }; + + server.get('/tags/filter/search', () => { //eslint-disable-line + return response({results: [{text: 'monkey', count: 1}]}); + }); + + server.get('/users/search/users', () => { //eslint-disable-line + return response({users: [{username: "admin", name: "admin", + avatar_template: "/letter_avatar_proxy/v2/letter/a/3ec8ea/{size}.png"}]}); + }); + + server.get('/admin/groups.json', () => { //eslint-disable-line + return response([{id: 2, automatic: true, name: "moderators", user_count: 4, alias_level: 0, + visible: true, automatic_membership_email_domains: null, automatic_membership_retroactive: false, + primary_group: false, title: null, grant_trust_level: null, incoming_email: null, + notification_level: null, has_messages: true, is_member: true, mentionable: false, + flair_url: null, flair_bg_color: null, flair_color: null}]); + }); + + server.get('/badges.json', () => { //eslint-disable-line + return response({badge_types: [{id: 3, name: "Bronze", sort_order: 7}], + badge_groupings: [{id: 1, name: "Getting Started", description: null, position: 10, system: true}], + badges: [{id: 17, name: "Reader", description: "Read every reply in a topic with more than 100 replies", + grant_count: 0, allow_title: false, multiple_grant: false, icon: "fa-certificate", image: null, + listable: true, enabled: true, badge_grouping_id: 1, system: true, + long_description: "This badge is granted the first time you read a long topic with more than 100 replies. Reading a conversation closely helps you follow the discussion, understand different viewpoints, and leads to more interesting conversations. The more you read, the better the conversation gets. As we like to say, Reading is Fundamental! :slight_smile:\n", + slug: "reader", has_badge: false, badge_type_id: 3}]}); + }); + } +}); test("perform various searches", assert => { visit("/search"); @@ -9,13 +47,198 @@ test("perform various searches", assert => { assert.ok(find('.fps-topic').length === 0); }); - fillIn('.search input', 'none'); + fillIn('.search input.full-page-search', 'none'); click('.search .btn-primary'); andThen(() => assert.ok(find('.fps-topic').length === 0), 'has no results'); - fillIn('.search input', 'posts'); + fillIn('.search input.full-page-search', 'posts'); click('.search .btn-primary'); andThen(() => assert.ok(find('.fps-topic').length === 1, 'has one post')); }); + +test("open advanced search", assert => { + visit("/search"); + + andThen(() => assert.ok(exists('.search .search-advanced'), 'shows advanced search panel')); + + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + + andThen(() => assert.ok(visible('.search-advanced .search-options'), '"search-options" is visible')); +}); + +test("validate population of advanced search", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'test user:admin #bug group:moderators badge:Reader tags:monkey in:likes status:open after:5 posts_count:10'); + click('.search-advanced h3.panel-title'); + + andThen(() => { + assert.ok(exists('.search-options span:contains("admin")'), 'has "admin" pre-populated'); + assert.ok(exists('.search-options .category-combobox .select2-choice .select2-chosen:contains("bug")'), 'has "bug" pre-populated'); + assert.ok(exists('.search-options span:contains("moderators")'), 'has "moderators" pre-populated'); + assert.ok(exists('.search-options span:contains("Reader")'), 'has "Reader" pre-populated'); + assert.ok(exists('.search-options .tag-chooser .tag-monkey'), 'has "monkey" pre-populated'); + assert.ok(exists('.search-options .combobox .select2-choice .select2-chosen:contains("I liked")'), 'has "I liked" pre-populated'); + assert.ok(exists('.search-options .combobox .select2-choice .select2-chosen:contains("are open")'), 'has "are open" pre-populated'); + assert.ok(exists('.search-options .combobox .select2-choice .select2-chosen:contains("after")'), 'has "after" pre-populated'); + assert.equal(find('.search-options #search-post-date').val(), "5", 'has "5" pre-populated'); + assert.equal(find('.search-options #search-posts-count').val(), "10", 'has "10" pre-populated'); + }); +}); + +test("update username through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + fillIn('.search-options .user-selector', 'admin'); + click('.search-options .user-selector'); + keyEvent('.search-options .user-selector', 'keydown', 8); + + andThen(() => { + waitFor(() => { + assert.ok(visible('.search-options .autocomplete'), '"autocomplete" popup is visible'); + assert.ok(exists('.search-options .autocomplete ul li a span.username:contains("admin")'), '"autocomplete" popup has an entry for "admin"'); + + click('.search-options .autocomplete ul li a:first'); + + andThen(() => { + assert.ok(exists('.search-options span:contains("admin")'), 'has "admin" pre-populated'); + assert.equal(find('.search input.full-page-search').val(), "none user:admin", 'has updated search term to "none user:admin"'); + }); + }); + }); +}); + +test("update category through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + selectDropdown('.search-options .category-combobox', 4); + click('.search-options'); // need to click off the combobox for the search-term to get updated + + andThen(() => { + assert.ok(exists('.search-options .category-combobox .select2-choice .select2-chosen:contains("faq")'), 'has "faq" populated'); + assert.equal(find('.search input.full-page-search').val(), "none #faq", 'has updated search term to "none #faq"'); + }); +}); + +test("update group through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + fillIn('.search-options .group-selector', 'moderators'); + click('.search-options .group-selector'); + keyEvent('.search-options .group-selector', 'keydown', 8); + + andThen(() => { + waitFor(() => { + assert.ok(visible('.search-options .autocomplete'), '"autocomplete" popup is visible'); + assert.ok(exists('.search-options .autocomplete ul li a:contains("moderators")'), '"autocomplete" popup has an entry for "moderators"'); + + click('.search-options .autocomplete ul li a:first'); + + andThen(() => { + assert.ok(exists('.search-options span:contains("moderators")'), 'has "moderators" pre-populated'); + assert.equal(find('.search input.full-page-search').val(), "none group:moderators", 'has updated search term to "none group:moderators"'); + }); + }); + }); +}); + +test("update badges through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + fillIn('.search-options .badge-names', 'Reader'); + click('.search-options .badge-names'); + keyEvent('.search-options .badge-names', 'keydown', 8); + + andThen(() => { + waitFor(() => { + assert.ok(visible('.search-options .autocomplete'), '"autocomplete" popup is visible'); + assert.ok(exists('.search-options .autocomplete ul li a:contains("Reader")'), '"autocomplete" popup has an entry for "Reader"'); + + click('.search-options .autocomplete ul li a:first'); + + andThen(() => { + assert.ok(exists('.search-options span:contains("Reader")'), 'has "Reader" pre-populated'); + assert.equal(find('.search input.full-page-search').val(), "none badge:Reader", 'has updated search term to "none badge:Reader"'); + }); + }); + }); +}); + +// test("update tags through advanced search ui", assert => { +// visit("/search"); +// fillIn('.search input.full-page-search', 'none'); +// click('.search-advanced h3.panel-title'); +// +// keyEvent('.search-options .tag-chooser input.select2-input', 'keydown', 110); +// fillIn('.search-options .tag-chooser input.select2-input', 'monkey'); +// keyEvent('.search-options .tag-chooser input.select2-input', 'keyup', 110); +// +// andThen(() => { +// waitFor(() => { +// click('li.select2-result-selectable:first'); +// andThen(() => { +// assert.ok(exists('.search-options .tag-chooser .tag-monkey'), 'has "monkey" pre-populated'); +// assert.equal(find('.search input.full-page-search').val(), "none tags:monkey", 'has updated search term to "none tags:monkey"'); +// }); +// }); +// }); +// }); + +test("update in filter through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + selectDropdown('.search-options #s2id_in', 'likes'); + fillIn('.search-options #in', 'likes'); + + andThen(() => { + assert.ok(exists('.search-options #s2id_in .select2-choice .select2-chosen:contains("I liked")'), 'has "I liked" populated'); + assert.equal(find('.search input.full-page-search').val(), "none in:likes", 'has updated search term to "none in:likes"'); + }); +}); + +test("update status through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + selectDropdown('.search-options #s2id_status', 'closed'); + fillIn('.search-options #status', 'closed'); + + andThen(() => { + assert.ok(exists('.search-options #s2id_status .select2-choice .select2-chosen:contains("are closed")'), 'has "are closed" populated'); + assert.equal(find('.search input.full-page-search').val(), "none status:closed", 'has updated search term to "none status:closed"'); + }); +}); + +test("update post time through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + fillIn('#search-post-date', '5'); + selectDropdown('.search-options #s2id_postTime', 'after'); + fillIn('.search-options #postTime', 'after'); + + andThen(() => { + assert.ok(exists('.search-options #s2id_postTime .select2-choice .select2-chosen:contains("after")'), 'has "after" populated'); + assert.equal(find('.search-options #search-post-date').val(), "5", 'has "5" populated'); + assert.equal(find('.search input.full-page-search').val(), "none after:5", 'has updated search term to "none after:5"'); + }); +}); + +test("update posts count through advanced search ui", assert => { + visit("/search"); + fillIn('.search input.full-page-search', 'none'); + click('.search-advanced h3.panel-title'); + fillIn('#search-posts-count', '5'); + + andThen(() => { + assert.equal(find('.search-options #search-posts-count').val(), "5", 'has "5" populated'); + assert.equal(find('.search input.full-page-search').val(), "none posts_count:5", 'has updated search term to "none posts_count:5"'); + }); +}); diff --git a/test/javascripts/helpers/qunit-helpers.js.es6 b/test/javascripts/helpers/qunit-helpers.js.es6 index ed5a51e1e3f..1fe326b36bd 100644 --- a/test/javascripts/helpers/qunit-helpers.js.es6 +++ b/test/javascripts/helpers/qunit-helpers.js.es6 @@ -109,6 +109,15 @@ function blank(obj, text) { ok(Ember.isEmpty(obj), text); } +function waitFor(callback, timeout) { + timeout = timeout || 500; + stop(); + Ember.run.later(() => { + callback(); + start(); + }, timeout); +} + export { acceptance, controllerFor, asyncTestDiscourse, @@ -116,4 +125,5 @@ export { acceptance, logIn, currentUser, blank, - present }; + present, + waitFor }; From dc87b6943ad84f3fcc8e6c631d1bb1475fa9b7bd Mon Sep 17 00:00:00 2001 From: cpradio Date: Sat, 8 Oct 2016 09:29:05 -0400 Subject: [PATCH 3/3] Fix the ID for postTime --- .../discourse/templates/components/search-advanced-options.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs index fb6997ee627..0e2e54c500f 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -63,7 +63,7 @@
- {{combo-box id="post-time" valueAttribute="value" content=postTimeOptions value=searchedTerms.time.when}} + {{combo-box id="postTime" valueAttribute="value" content=postTimeOptions value=searchedTerms.time.when}} {{input type="text" value=searchedTerms.time.days class="input-small" id='search-post-date'}}