From 4c7a21c76e09de80ae3e81dae784dd07bceb5b02 Mon Sep 17 00:00:00 2001 From: cpradio Date: Mon, 20 Feb 2017 21:49:24 -0500 Subject: [PATCH] FEATURE: Autocomplete support on advanced search PERF: Extract autocomplete initialization to a function PERF: Create a REGEXP_TAGS_REPLACE regex to remove a chained .replace call FIX: autocomplete positioning FIX: Collapsing/Expanding Advanced Search doesn't wipe out Advanced Search Terms from search query. FIX: Populate Category when query/search term is updated FIX: Using enter to complete autocomplete doesn't automatically send you to full page search --- .../components/search-advanced-options.js.es6 | 38 ++++++++++--------- .../components/search-text-field.js.es6 | 6 ++- .../discourse/lib/autocomplete.js.es6 | 17 +++++++-- .../javascripts/discourse/lib/search.js.es6 | 35 ++++++++++++++++- .../components/search-advanced-options.hbs | 2 +- .../discourse/widgets/header.js.es6 | 7 +++- .../widgets/search-menu-controls.js.es6 | 16 +++++++- 7 files changed, 94 insertions(+), 27 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 23bdac794b4..4c658c63580 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -3,24 +3,26 @@ import { escapeExpression } from 'discourse/lib/utilities'; const REGEXP_BLOCKS = /(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/g; -const REGEXP_USERNAME_PREFIX = /(user:|@)/ig; -const REGEXP_CATEGORY_PREFIX = /(category:|#)/ig; -const REGEXP_GROUP_PREFIX = /group:/ig; -const REGEXP_BADGE_PREFIX = /badge:/ig; -const REGEXP_TAGS_PREFIX = /tags?:/ig; -const REGEXP_IN_PREFIX = /in:/ig; -const REGEXP_STATUS_PREFIX = /status:/ig; -const REGEXP_MIN_POST_COUNT_PREFIX = /min_post_count:/ig; -const REGEXP_POST_TIME_PREFIX = /(before|after):/ig; +const REGEXP_USERNAME_PREFIX = /^(user:|@)/ig; +const REGEXP_CATEGORY_PREFIX = /^(category:|#)/ig; +const REGEXP_GROUP_PREFIX = /^group:/ig; +const REGEXP_BADGE_PREFIX = /^badge:/ig; +const REGEXP_TAGS_PREFIX = /^(tags?:|#(?=[a-z0-9\-]+::tag))/ig; +const REGEXP_IN_PREFIX = /^in:/ig; +const REGEXP_STATUS_PREFIX = /^status:/ig; +const REGEXP_MIN_POST_COUNT_PREFIX = /^min_post_count:/ig; +const REGEXP_POST_TIME_PREFIX = /^(before|after):/ig; +const REGEXP_TAGS_REPLACE = /(^(tags?:|#(?=[a-z0-9\-]+::tag))|::tag\s?$)/ig; -const REGEXP_IN_MATCH = /in:(posted|watching|tracking|bookmarks|first|pinned|unpinned)/ig; -const REGEXP_SPECIAL_IN_LIKES_MATCH = /in:likes/ig; -const REGEXP_SPECIAL_IN_PRIVATE_MATCH = /in:private/ig; -const REGEXP_SPECIAL_IN_WIKI_MATCH = /in:wiki/ig; -const REGEXP_CATEGORY_SLUG = /(\#[a-zA-Z0-9\-:]+)/ig; -const REGEXP_CATEGORY_ID = /(category:[0-9]+)/ig; -const REGEXP_POST_TIME_WHEN = /(before|after)/ig; +const REGEXP_IN_MATCH = /^in:(posted|watching|tracking|bookmarks|first|pinned|unpinned)/ig; +const REGEXP_SPECIAL_IN_LIKES_MATCH = /^in:likes/ig; +const REGEXP_SPECIAL_IN_PRIVATE_MATCH = /^in:private/ig; +const REGEXP_SPECIAL_IN_WIKI_MATCH = /^in:wiki/ig; + +const REGEXP_CATEGORY_SLUG = /^(\#[a-zA-Z0-9\-:]+)/ig; +const REGEXP_CATEGORY_ID = /^(category:[0-9]+)/ig; +const REGEXP_POST_TIME_WHEN = /^(before|after)/ig; export default Em.Component.extend({ classNames: ['search-advanced-options'], @@ -48,8 +50,8 @@ export default Em.Component.extend({ init() { this._super(); + this._init(); Ember.run.scheduleOnce('afterRender', () => { - this._init(); this._update(); }); }, @@ -228,7 +230,7 @@ export default Em.Component.extend({ if (match.length !== 0) { const existingInput = _.isArray(tags) ? tags.join(',') : tags; - const userInput = match[0].replace(REGEXP_TAGS_PREFIX, ''); + const userInput = match[0].replace(REGEXP_TAGS_REPLACE, ''); if (existingInput !== userInput) { this.set('searchedTerms.tags', (userInput.length !== 0) ? userInput.split(',') : []); diff --git a/app/assets/javascripts/discourse/components/search-text-field.js.es6 b/app/assets/javascripts/discourse/components/search-text-field.js.es6 index a086a51f32a..85547425795 100644 --- a/app/assets/javascripts/discourse/components/search-text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/search-text-field.js.es6 @@ -1,6 +1,7 @@ import computed from 'ember-addons/ember-computed-decorators'; import { on } from 'ember-addons/ember-computed-decorators'; import TextField from 'discourse/components/text-field'; +import { applySearchAutocomplete } from "discourse/lib/search"; export default TextField.extend({ @computed('searchService.searchContextEnabled') @@ -10,10 +11,13 @@ export default TextField.extend({ @on("didInsertElement") becomeFocused() { + const $searchInput = this.$(); + applySearchAutocomplete($searchInput, this.siteSettings); + if (!this.get('hasAutofocus')) { return; } // iOS is crazy, without this we will not be // at the top of the page $(window).scrollTop(0); - this.$().focus(); + $searchInput.focus(); } }); diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 index 5e1d404bc9d..c30a1071039 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 +++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 @@ -82,7 +82,7 @@ export default function(options) { let prevTerm = null; // input is handled differently - const isInput = this[0].tagName === "INPUT"; + const isInput = this[0].tagName === "INPUT" && !options.treatAsTextarea; let inputSelectedItems = []; function closeAutocomplete() { @@ -175,8 +175,10 @@ export default function(options) { wrap.width(width); } - if(options.single) { - this.css("width","100%"); + if(options.single && !options.width) { + this.css("width", "100%"); + } else if (options.width) { + this.css("width", options.width); } else { this.width(150); } @@ -245,6 +247,13 @@ export default function(options) { }; vOffset = -32; hOffset = 0; + } if (options.treatAsTextarea) { + pos = me.caretPosition({ + pos: completeStart, + key: options.key + }); + hOffset = 27; + vOffset = -32; } else { pos = me.caretPosition({ pos: completeStart, @@ -258,7 +267,7 @@ export default function(options) { me.parent().append(div); - if (!isInput) { + if (!isInput && !options.treatAsTextarea) { vOffset = div.height(); if ((window.innerHeight - me.outerHeight() - $("header.d-header").innerHeight()) < vOffset) { diff --git a/app/assets/javascripts/discourse/lib/search.js.es6 b/app/assets/javascripts/discourse/lib/search.js.es6 index 9dc3b38e2f1..fea2dd7f136 100644 --- a/app/assets/javascripts/discourse/lib/search.js.es6 +++ b/app/assets/javascripts/discourse/lib/search.js.es6 @@ -1,9 +1,14 @@ import { ajax } from 'discourse/lib/ajax'; +import { findRawTemplate } from 'discourse/lib/raw-templates'; +import { TAG_HASHTAG_POSTFIX } from 'discourse/lib/tag-hashtags'; +import { SEPARATOR } from 'discourse/lib/category-hashtags'; +import Category from 'discourse/models/category'; +import { search as searchCategoryTag } from 'discourse/lib/category-tag-search'; +import userSearch from 'discourse/lib/user-search'; export function translateResults(results, opts) { const User = require('discourse/models/user').default; - const Category = require('discourse/models/category').default; const Post = require('discourse/models/post').default; const Topic = require('discourse/models/topic').default; @@ -124,3 +129,31 @@ export function isValidSearchTerm(searchTerm) { return false; } }; + +export function applySearchAutocomplete($input, siteSettings) { + $input.autocomplete({ + template: findRawTemplate('category-tag-autocomplete'), + key: '#', + width: '100%', + treatAsTextarea: true, + transformComplete(obj) { + if (obj.model) { + return Category.slugFor(obj.model, SEPARATOR); + } else { + return `${obj.text}${TAG_HASHTAG_POSTFIX}`; + } + }, + dataSource(term) { + return searchCategoryTag(term, siteSettings); + } + }); + + $input.autocomplete({ + template: findRawTemplate('user-selector-autocomplete'), + dataSource: term => userSearch({ term, undefined, includeGroups: true }), + key: "@", + width: '100%', + treatAsTextarea: true, + transformComplete: v => v.username || v.name + }); +}; 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 6df6fcb44da..d56dc781be6 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -13,7 +13,7 @@
- {{category-selector categories=searchedTerms.category single="true"}} + {{category-selector categories=searchedTerms.category single="true" canReceiveUpdates="true"}}
diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index 252baf173e2..fb8a4df32ce 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -3,6 +3,7 @@ import { iconNode } from 'discourse/helpers/fa-icon-node'; import { avatarImg } from 'discourse/widgets/post'; import DiscourseURL from 'discourse/lib/url'; import { wantsNewWindow } from 'discourse/lib/intercept-click'; +import { applySearchAutocomplete } from "discourse/lib/search"; import { h } from 'virtual-dom'; @@ -256,7 +257,11 @@ export default createWidget('header', { this.updateHighlight(); if (this.state.searchVisible) { - Ember.run.schedule('afterRender', () => $('#search-term').focus().select()); + Ember.run.schedule('afterRender', () => { + const $searchInput = $('#search-term'); + $searchInput.focus().select(); + applySearchAutocomplete($searchInput, this.siteSettings); + }); } }, diff --git a/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 index 70d046f7d59..0fe748669ef 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 @@ -5,6 +5,11 @@ import { createWidget } from 'discourse/widgets/widget'; createWidget('search-term', { tagName: 'input', buildId: () => 'search-term', + buildKey: (attrs) => `search-term-${attrs.id}`, + + defaultState() { + return { autocompleteIsOpen: false }; + }, buildAttributes(attrs) { return { type: 'text', @@ -12,8 +17,17 @@ createWidget('search-term', { placeholder: attrs.contextEnabled ? "" : I18n.t('search.title') }; }, + keyDown(e) { + const state = this.state; + if ($(`#${this.buildId()}`).parent().find('.autocomplete').length !== 0) { + state.autocompleteIsOpen = true; + } else { + state.autocompleteIsOpen = false; + } + }, + keyUp(e) { - if (e.which === 13) { + if (e.which === 13 && !this.state.autocompleteIsOpen) { return this.sendWidgetAction('fullSearch'); }