DEV: Upgrade search-menu to glimmer (#20482)

# Top level view
This PR is the first version of converting the search menu and its logic from (deprecated) widgets to glimmer components. The changes are hidden behind a group based feature flag. This will give us the ability to test the new implementation in a production setting before fully committing to the new search menu.

# What has changed
The majority of the logic from the widget implementation has been updated to fit within the context of a glimmer component, but it has not fundamentally changed. Instead of having a single widget - [search-menu.js](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/app/widgets/search-menu.js) - that built the bulk of the search menu logic, we split the logic into (20+) bite size components. This greatly increases the readability and makes extending a component in the search menu much more straightforward.

That being said, certain pieces needed to be rewritten from scratch as they did not translate from widget -> glimmer, or there was a general code upgraded needed. There are a few of these changes worth noting:

### Search Service
**Search Term** -> In the widget implementation we had a overly complex way of managing the current search term. We tracked the search term across multiple different states (`term`, `opts.term`, `searchData.term`) causing headaches. This PR introduces a single source of truth: 
```js
this.search.activeGlobalSearchTerm
```
This tracked value is available anywhere the `search` service is injected. In the case the search term should be needs to be updated you can call 
```js
this.search.activeGlobalSearchTerm = "foo"
```
 
**event listeners** -> In the widget implementation we defined event listeners **only** on the search input to handle things such as 
- keyboard navigation / shortcuts
- closing the search menu
- performing a search with "enter"

Having this in one place caused a lot of bloat in our logic as we had to handle multiple different cases in one location. Do _x_ if it is this element, but do _y_ if it is another. This PR updates the event listeners to be attached to individual components, allowing for a more fine tuned set of actions per element. To not duplicate logic across multiple components, we have condensed shared logic to actions on the search service to be reused. For example - `this.search.handleArrowUpOrDown` - to handle keyboard navigation.

### Search Context
We have unique logic based on the current search context (topic / tag / category / user / etc). This context is set within a models route file. We have updated the search service with a tracked value `searchContext` that can be utilized and updated from any component where the search service is injected.

```js
# before
this.searchService.set("searchContext", user.searchContext);

# after
this.searchService.searchContext = user.searchContext;
```

# Views
<img width="434" alt="Screenshot 2023-06-15 at 11 01 01 AM" src="https://github.com/discourse/discourse/assets/50783505/ef57e8e6-4e7b-4ba0-a770-8f2ed6310569">

<img width="418" alt="Screenshot 2023-06-15 at 11 04 11 AM" src="https://github.com/discourse/discourse/assets/50783505/2c1e0b38-d12c-4339-a1d5-04f0c1932b08">

<img width="413" alt="Screenshot 2023-06-15 at 11 04 34 AM" src="https://github.com/discourse/discourse/assets/50783505/b871d164-88cb-405e-9b78-d326a6f63686">

<img width="419" alt="Screenshot 2023-06-15 at 11 07 51 AM" src="https://github.com/discourse/discourse/assets/50783505/c7309a19-f541-47f4-94ef-10fa65658d8c">

<img width="424" alt="Screenshot 2023-06-15 at 11 04 48 AM" src="https://github.com/discourse/discourse/assets/50783505/f3dba06e-b029-431c-b3d0-36727b9e6dce">

<img width="415" alt="Screenshot 2023-06-15 at 11 08 57 AM" src="https://github.com/discourse/discourse/assets/50783505/ad4e7250-040c-4d06-bf06-99652f4c7b7c">
This commit is contained in:
Isaac Janzen 2023-06-16 09:24:07 -05:00 committed by GitHub
parent 251d6f0627
commit a2b038ffe7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 3009 additions and 46 deletions

View File

@ -0,0 +1,7 @@
<div class={{concat-class "menu-panel" @animationClass}} data-max-width="500">
<div class="panel-body">
<div class="panel-body-contents">
{{yield}}
</div>
</div>
</div>

View File

@ -0,0 +1,25 @@
<MenuPanel @animationClass={{@animationClass}}>
<SearchMenu::MenuPanelContents
@inTopicContext={{this.inTopicContext}}
@clearTopicContext={{this.clearTopicContext}}
@clearPMInboxContext={{this.clearPMInboxContext}}
@inPMInboxContext={{this.inPMInboxContext}}
@searchTermChanged={{this.searchTermChanged}}
@loading={{this.loading}}
@fullSearchUrl={{this.fullSearchUrl}}
@fullSearch={{this.fullSearch}}
@triggerSearch={{this.triggerSearch}}
@clearSearch={{this.clearSearch}}
@includesTopics={{this.includesTopics}}
@noResults={{this.noResults}}
@results={{this.results}}
@invalidTerm={{this.invalidTerm}}
@suggestionKeyword={{this.suggestionKeyword}}
@suggestionResults={{this.suggestionResults}}
@searchTopics={{this.includesTopics}}
@typeFilter={{this.typeFilter}}
@updateTypeFilter={{this.updateTypeFilter}}
@toggleSearchMenu={{@toggleSearchMenu}}
@closeSearchMenu={{@closeSearchMenu}}
/>
</MenuPanel>

View File

@ -0,0 +1,294 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { bind } from "discourse-common/utils/decorators";
import { tracked } from "@glimmer/tracking";
import {
isValidSearchTerm,
searchForTerm,
updateRecentSearches,
} from "discourse/lib/search";
import DiscourseURL from "discourse/lib/url";
import discourseDebounce from "discourse-common/lib/debounce";
import getURL from "discourse-common/lib/get-url";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { Promise } from "rsvp";
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
import userSearch from "discourse/lib/user-search";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import { cancel } from "@ember/runloop";
const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi;
const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi;
const SUGGESTIONS_REGEXP = /(in:|status:|order:|:)([a-zA-Z]*)$/gi;
export const SEARCH_INPUT_ID = "search-term";
export const SEARCH_BUTTON_ID = "search-button";
export const MODIFIER_REGEXP = /.*(\#|\@|:).*$/gi;
export const DEFAULT_TYPE_FILTER = "exclude_topics";
export function focusSearchInput() {
document.getElementById(SEARCH_INPUT_ID).focus();
}
export function focusSearchButton() {
document.getElementById(SEARCH_BUTTON_ID).focus();
}
export default class SearchMenu extends Component {
@service search;
@service currentUser;
@service siteSettings;
@service appEvents;
@tracked inTopicContext = this.args.inTopicContext;
@tracked loading = false;
@tracked results = {};
@tracked noResults = false;
@tracked inPMInboxContext =
this.search.searchContext?.type === "private_messages";
@tracked typeFilter = DEFAULT_TYPE_FILTER;
@tracked suggestionKeyword = false;
@tracked suggestionResults = [];
@tracked invalidTerm = false;
_debouncer = null;
_activeSearch = null;
get includesTopics() {
return this.typeFilter !== DEFAULT_TYPE_FILTER;
}
get searchContext() {
if (this.inTopicContext || this.inPMInboxContext) {
return this.search.searchContext;
}
return false;
}
@bind
fullSearchUrl(opts) {
let url = "/search";
let params = new URLSearchParams();
if (this.search.activeGlobalSearchTerm) {
let q = this.search.activeGlobalSearchTerm;
if (this.searchContext?.type === "topic") {
q += ` topic:${this.searchContext.id}`;
} else if (this.searchContext?.type === "private_messages") {
q += " in:messages";
}
params.set("q", q);
}
if (opts?.expanded) {
params.set("expanded", "true");
}
if (params.toString() !== "") {
url = `${url}?${params}`;
}
return getURL(url);
}
@bind
clearSearch(e) {
e.stopPropagation();
e.preventDefault();
this.search.activeGlobalSearchTerm = "";
focusSearchInput();
this.triggerSearch();
}
@action
searchTermChanged(term, opts = {}) {
this.typeFilter = opts.searchTopics ? null : DEFAULT_TYPE_FILTER;
if (opts.setTopicContext) {
this.inTopicContext = true;
}
this.search.activeGlobalSearchTerm = term;
this.triggerSearch();
}
@action
fullSearch() {
this.loading = false;
const url = this.fullSearchUrl();
if (url) {
DiscourseURL.routeTo(url);
}
}
@action
updateTypeFilter(value) {
this.typeFilter = value;
}
@action
clearPMInboxContext() {
this.inPMInboxContext = false;
}
@action
clearTopicContext() {
this.inTopicContext = false;
}
// for cancelling debounced search
cancel() {
if (this._activeSearch) {
this._activeSearch.abort();
this._activeSearch = null;
}
}
async perform() {
this.cancel();
const matchSuggestions = this.matchesSuggestions();
if (matchSuggestions) {
this.noResults = true;
this.results = {};
this.loading = false;
this.suggestionResults = [];
if (matchSuggestions.type === "category") {
const categorySearchTerm = matchSuggestions.categoriesMatch[0].replace(
"#",
""
);
const categoryTagSearch = searchCategoryTag(
categorySearchTerm,
this.siteSettings
);
Promise.resolve(categoryTagSearch).then((results) => {
if (results !== CANCELLED_STATUS) {
this.suggestionResults = results;
this.suggestionKeyword = "#";
}
});
} else if (matchSuggestions.type === "username") {
const userSearchTerm = matchSuggestions.usernamesMatch[0].replace(
"@",
""
);
const opts = { includeGroups: true, limit: 6 };
if (userSearchTerm.length > 0) {
opts.term = userSearchTerm;
} else {
opts.lastSeenUsers = true;
}
userSearch(opts).then((result) => {
if (result?.users?.length > 0) {
this.suggestionResults = result.users;
this.suggestionKeyword = "@";
} else {
this.noResults = true;
this.suggestionKeyword = false;
}
});
} else {
this.suggestionKeyword = matchSuggestions[0];
}
return;
}
this.suggestionKeyword = false;
if (!this.search.activeGlobalSearchTerm) {
this.noResults = false;
this.results = {};
this.loading = false;
this.invalidTerm = false;
} else if (
!isValidSearchTerm(this.search.activeGlobalSearchTerm, this.siteSettings)
) {
this.noResults = true;
this.results = {};
this.loading = false;
this.invalidTerm = true;
} else {
this.invalidTerm = false;
this._activeSearch = searchForTerm(this.search.activeGlobalSearchTerm, {
typeFilter: this.typeFilter,
fullSearchUrl: this.fullSearchUrl,
searchContext: this.searchContext,
});
this._activeSearch
.then((results) => {
// we ensure the current search term is the one used
// when starting the query
if (results) {
if (this.searchContext) {
this.appEvents.trigger("post-stream:refresh", {
force: true,
});
}
this.noResults = results.resultTypes.length === 0;
this.results = results;
}
})
.catch(popupAjaxError)
.finally(() => {
this.loading = false;
});
}
}
matchesSuggestions() {
if (
this.search.activeGlobalSearchTerm === undefined ||
this.includesTopics
) {
return false;
}
const term = this.search.activeGlobalSearchTerm.trim();
const categoriesMatch = term.match(CATEGORY_SLUG_REGEXP);
if (categoriesMatch) {
return { type: "category", categoriesMatch };
}
const usernamesMatch = term.match(USERNAME_REGEXP);
if (usernamesMatch) {
return { type: "username", usernamesMatch };
}
const suggestionsMatch = term.match(SUGGESTIONS_REGEXP);
if (suggestionsMatch) {
return suggestionsMatch;
}
return false;
}
@action
triggerSearch() {
this.noResults = false;
if (this.includesTopics) {
if (this.search.contextType === "topic") {
this.search.highlightTerm = this.search.activeGlobalSearchTerm;
}
this.loading = true;
cancel(this._debouncer);
this.perform();
if (this.currentUser) {
updateRecentSearches(
this.currentUser,
this.search.activeGlobalSearchTerm
);
}
} else {
this.loading = false;
if (!this.inTopicContext) {
this._debouncer = discourseDebounce(this, this.perform, 400);
}
}
}
}

View File

@ -0,0 +1,7 @@
<a
class="show-advanced-search"
title={{i18n "search.open_advanced"}}
href={{@href}}
>
{{d-icon "sliders-h"}}
</a>

View File

@ -0,0 +1,8 @@
<div class="browser-search-tip">
<span class="tip-label">
{{this.translatedLabel}}
</span>
<span class="tip-description">
{{i18n "search.browser_tip_description"}}
</span>
</div>

View File

@ -0,0 +1,9 @@
import Component from "@glimmer/component";
import I18n from "I18n";
import { translateModKey } from "discourse/lib/utilities";
export default class BrowserSearchTip extends Component {
get translatedLabel() {
return I18n.t("search.browser_tip", { modifier: translateModKey("Meta+") });
}
}

View File

@ -0,0 +1,9 @@
<a
class="clear-search"
aria-label="clear_input"
title={{i18n "search.clear_search"}}
href
{{on "click" @clearSearch}}
>
{{d-icon "times"}}
</a>

View File

@ -0,0 +1 @@
{{this.content}}

View File

@ -0,0 +1,16 @@
import Component from "@glimmer/component";
import highlightSearch from "discourse/lib/highlight-search";
import { inject as service } from "@ember/service";
export default class HighlightedSearch extends Component {
@service search;
constructor() {
super(...arguments);
const span = document.createElement("span");
span.textContent = this.args.string;
this.content = span;
highlightSearch(span, this.search.activeGlobalSearchTerm);
}
}

View File

@ -0,0 +1,65 @@
<div class="search-input">
{{#if @inTopicContext}}
<DButton
@icon="times"
@label="search.in_this_topic"
@title="search.in_this_topic_tooltip"
class="btn btn-small search-context"
@action={{@clearTopicContext}}
@iconRight={{true}}
/>
{{else if @inPMInboxContext}}
<DButton
@icon="times"
@label="search.in_messages"
@title="search.in_messages_tooltip"
@class="btn btn-small search-context"
@action={{@clearPMInboxContext}}
@iconRight={{true}}
/>
{{/if}}
<SearchMenu::SearchTerm
@searchTermChanged={{@searchTermChanged}}
@typeFilter={{@typeFilter}}
@updateTypeFilter={{@updateTypeFilter}}
@triggerSearch={{@triggerSearch}}
@fullSearch={{@fullSearch}}
@clearPMInboxContext={{@clearPMInboxContext}}
@clearTopicContext={{@clearTopicContext}}
@closeSearchMenu={{@closeSearchMenu}}
/>
{{#if @loading}}
<div class="searching">
{{loading-spinner}}
</div>
{{else}}
<div class="searching">
{{#if this.search.activeGlobalSearchTerm}}
<SearchMenu::ClearButton @clearSearch={{@clearSearch}} />
{{/if}}
<SearchMenu::AdvancedButton @href={{this.advancedSearchButtonHref}} />
</div>
{{/if}}
</div>
{{#if (and @inTopicContext (not @includesTopics))}}
<SearchMenu::BrowserSearchTip />
{{else}}
{{#unless @loading}}
<SearchMenu::Results
@noResults={{@noResults}}
@results={{@results}}
@invalidTerm={{@invalidTerm}}
@suggestionKeyword={{@suggestionKeyword}}
@suggestionResults={{@suggestionResults}}
@searchTopics={{@includesTopics}}
@inPMInboxContext={{@inPMInboxContext}}
@triggerSearch={{@triggerSearch}}
@updateTypeFilter={{@updateTypeFilter}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/unless}}
{{/if}}

View File

@ -0,0 +1,10 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class MenuPanelContents extends Component {
@service search;
get advancedSearchButtonHref() {
return this.args.fullSearchUrl({ expanded: true });
}
}

View File

@ -0,0 +1,52 @@
<div class="results">
{{#if @suggestionKeyword}}
<SearchMenu::Results::Assistant
@suggestionKeyword={{@suggestionKeyword}}
@results={{@suggestionResults}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{else if this.termTooShort}}
<div class="no-results">{{i18n "search.too_short"}}</div>
{{else if this.noTopicResults}}
<div class="no-results">{{i18n "search.no_results"}}</div>
{{else if this.renderInitialOptions}}
<SearchMenu::Results::InitialOptions
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{else}}
{{#if @searchTopics}}
{{! render results after a search has been performed }}
{{#if this.resultTypesWithComponent}}
<SearchMenu::Results::Types
@resultTypes={{this.resultTypesWithComponent}}
@topicResultsOnly={{true}}
@closeSearchMenu={{@closeSearchMenu}}
/>
<SearchMenu::Results::MoreLink
@updateTypeFilter={{@updateTypeFilter}}
@triggerSearch={{@triggerSearch}}
@resultTypes={{this.resultTypesWithComponent}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/if}}
{{else}}
{{#unless @inPMInboxContext}}
{{! render the first couple suggestions before a search has been performed}}
<SearchMenu::Results::InitialOptions
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{#if this.resultTypesWithComponent}}
<SearchMenu::Results::Types
@resultTypes={{this.resultTypesWithComponent}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/if}}
{{/unless}}
{{/if}}
{{/if}}
</div>

View File

@ -0,0 +1,53 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import TopicViewComponent from "./results/type/topic";
import PostViewComponent from "./results/type/post";
import UserViewComponent from "./results/type/user";
import TagViewComponent from "./results/type/tag";
import GroupViewComponent from "./results/type/group";
import CategoryViewComponent from "./results/type/category";
const SEARCH_RESULTS_COMPONENT_TYPE = {
"search-result-category": CategoryViewComponent,
"search-result-topic": TopicViewComponent,
"search-result-post": PostViewComponent,
"search-result-user": UserViewComponent,
"search-result-tag": TagViewComponent,
"search-result-group": GroupViewComponent,
};
export default class Results extends Component {
@service search;
@tracked searchTopics = this.args.searchTopics;
get renderInitialOptions() {
return !this.search.activeGlobalSearchTerm && !this.args.inPMInboxContext;
}
get noTopicResults() {
return this.args.searchTopics && this.args.noResults;
}
get termTooShort() {
return this.args.searchTopics && this.args.invalidTerm;
}
get resultTypesWithComponent() {
let content = [];
this.args.results.resultTypes?.map((resultType) => {
content.push({
...resultType,
component: SEARCH_RESULTS_COMPONENT_TYPE[resultType.componentName],
});
});
return content;
}
@action
updateSearchTopics(value) {
this.searchTopics = value;
}
}

View File

@ -0,0 +1,56 @@
{{! template-lint-disable no-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}
<li
class="search-menu-assistant-item"
{{on "keydown" this.onKeydown}}
{{on "click" this.onClick}}
>
<a class="search-link" href={{this.href}}>
<span aria-label={{i18n "search.title"}}>
{{d-icon (or @icon "search")}}
</span>
{{#if this.prefix}}
<span class="search-item-prefix">
{{this.prefix}}
</span>
{{/if}}
{{#if @withInLabel}}
<span class="label-suffix">{{i18n "search.in"}}</span>
{{/if}}
{{#if @category}}
<SearchMenu::Results::Type::Category @result={{@category}} />
{{#if (and @tag @isIntersection)}}
<span class="search-item-tag">
{{d-icon "tag"}}{{@tag}}
</span>
{{/if}}
{{else if @tag}}
{{#if (and @isIntersection @additionalTags.length)}}
<span class="search-item-tag">{{this.tagsSlug}}</span>
{{else}}
<span class="search-item-tag">
<SearchMenu::Results::Type::Tag @result={{@tag}} />
</span>
{{/if}}
{{else if @user}}
<span class="search-item-user">
<SearchMenu::Results::Type::User @result={{@user}} />
</span>
{{/if}}
<span class="search-item-slug">
{{#if @suffix}}
<span class="label-suffix">{{@suffix}}</span>
{{/if}}
{{@label}}
</span>
{{#if @extraHint}}
<span class="extra-hint">
{{i18n "search.enter_hint"}}
</span>
{{/if}}
</a>
</li>

View File

@ -0,0 +1,100 @@
import Component from "@glimmer/component";
import getURL from "discourse-common/lib/get-url";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { debounce } from "discourse-common/utils/decorators";
import {
focusSearchButton,
focusSearchInput,
} from "discourse/components/search-menu";
export default class AssistantItem extends Component {
@service search;
@service appEvents;
icon = this.args.icon || "search";
get href() {
let href = "#";
if (this.args.category) {
href = this.args.category.url;
if (this.args.tags && this.args.isIntersection) {
href = getURL(`/tag/${this.args.tag}`);
}
} else if (
this.args.tags &&
this.args.isIntersection &&
this.args.additionalTags?.length
) {
href = getURL(`/tag/${this.args.tag}`);
}
return href;
}
get prefix() {
let prefix = "";
if (this.args.suggestionKeyword !== "+") {
prefix =
this.search.activeGlobalSearchTerm
?.split(this.args.suggestionKeyword)[0]
.trim() || "";
if (prefix.length) {
prefix = `${prefix} `;
}
} else {
prefix = this.search.activeGlobalSearchTerm;
}
return prefix;
}
get tagsSlug() {
if (!this.args.tag || !this.args.additionalTags) {
return;
}
return `tags:${[this.args.tag, ...this.args.additionalTags].join("+")}`;
}
@action
onKeydown(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;
}
if (e.key === "Enter") {
this.itemSelected();
}
this.search.handleArrowUpOrDown(e);
e.stopPropagation();
e.preventDefault();
}
@action
onClick(e) {
this.itemSelected();
e.preventDefault();
return false;
}
@debounce(100)
itemSelected() {
let updatedValue = "";
if (this.args.slug) {
updatedValue = this.prefix.concat(this.args.slug);
} else {
updatedValue = this.prefix.trim();
}
const inTopicContext = this.search.searchContext?.type === "topic";
this.args.searchTermChanged(updatedValue, {
searchTopics: !inTopicContext || this.search.activeGlobalSearchTerm,
...(inTopicContext && { setTopicContext: true }),
});
focusSearchInput();
}
}

View File

@ -0,0 +1,81 @@
<ul class="search-menu-assistant">
{{! suggestion type keywords are mapped to SUGGESTION_KEYWORD_MAP }}
{{#if (eq this.suggestionType "tagIntersection")}}
{{#each @results as |result|}}
<SearchMenu::Results::AssistantItem
@tag={{result.tagName}}
@additionalTags={{result.additionalTags}}
@category={{result.category}}
@slug={{@slug}}
@withInLabel={{@withInLabel}}
@isIntersection={{true}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{@suggestionKeyword}}
/>
{{/each}}
{{else if (eq this.suggestionType "categoryOrTag")}}
{{#each @results as |result|}}
{{#if result.model}}
<SearchMenu::Results::AssistantItem
@category={{result.model}}
@slug={{get this.fullSlugForCategoryMap result.model.id}}
@withInLabel={{@withInLabel}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{@suggestionKeyword}}
/>
{{else}}
<SearchMenu::Results::AssistantItem
@tag={{result.name}}
@slug={{concat this.prefix "#" result.name}}
@withInLabel={{@withInLabel}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{@suggestionKeyword}}
/>
{{/if}}
{{/each}}
{{else if (eq this.suggestionType "user")}}
{{#if this.userMatchesInTopic}}
<SearchMenu::Results::AssistantItem
@extraHint={{true}}
@user={{this.user}}
@slug={{concat this.prefix "@" this.user.username}}
@suffix={{i18n "search.in_topics_posts"}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{@suggestionKeyword}}
/>
<SearchMenu::Results::AssistantItem
@user={{this.user}}
@slug={{concat this.prefix "@" this.user.username}}
@suffix={{i18n "search.in_this_topic"}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{@suggestionKeyword}}
/>
{{else}}
{{#each @results as |result|}}
<SearchMenu::Results::AssistantItem
@user={{result}}
@slug={{concat this.prefix "@" result.username}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{@suggestionKeyword}}
/>
{{/each}}
{{/if}}
{{else}}
{{#each this.suggestionShortcuts as |item|}}
<SearchMenu::Results::AssistantItem
@slug={{concat this.prefix item}}
@label={{item}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{@suggestionKeyword}}
/>
{{/each}}
{{/if}}
</ul>

View File

@ -0,0 +1,128 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
const suggestionShortcuts = [
"in:title",
"in:pinned",
"status:open",
"status:closed",
"status:public",
"status:noreplies",
"order:latest",
"order:views",
"order:likes",
"order:latest_topic",
];
const SUGGESTION_KEYWORD_MAP = {
"+": "tagIntersection",
"#": "categoryOrTag",
"@": "user",
};
export default class Assistant extends Component {
@service router;
@service currentUser;
@service siteSettings;
@service search;
constructor() {
super(...arguments);
if (this.currentUser) {
addSearchSuggestion("in:likes");
addSearchSuggestion("in:bookmarks");
addSearchSuggestion("in:mine");
addSearchSuggestion("in:messages");
addSearchSuggestion("in:seen");
addSearchSuggestion("in:tracking");
addSearchSuggestion("in:unseen");
addSearchSuggestion("in:watching");
}
if (this.siteSettings.tagging_enabled) {
addSearchSuggestion("in:tagged");
addSearchSuggestion("in:untagged");
}
}
get suggestionShortcuts() {
const shortcut = this.search.activeGlobalSearchTerm.split(" ").slice(-1);
const suggestions = suggestionShortcuts.filter((suggestion) =>
suggestion.includes(shortcut)
);
return suggestions.slice(0, 8);
}
get userMatchesInTopic() {
return (
this.args.results.length === 1 &&
this.router.currentRouteName.startsWith("topic.")
);
}
get suggestionType() {
switch (this.args.suggestionKeyword) {
case "+":
return SUGGESTION_KEYWORD_MAP[this.args.suggestionKeyword];
case "#":
return SUGGESTION_KEYWORD_MAP[this.args.suggestionKeyword];
case "@":
return SUGGESTION_KEYWORD_MAP[this.args.suggestionKeyword];
}
}
get prefix() {
let prefix = "";
if (this.args.suggestionKeyword !== "+") {
prefix =
this.args.slug?.split(this.args.suggestionKeyword)[0].trim() || "";
if (prefix.length) {
prefix = `${prefix} `;
}
} else {
this.args.results.forEach((result) => {
if (result.additionalTags) {
prefix =
this.args.slug?.split(" ").slice(0, -1).join(" ").trim() || "";
} else {
prefix = this.args.slug?.split("#")[0].trim() || "";
}
if (prefix.length) {
prefix = `${prefix} `;
}
});
}
return prefix;
}
// For all results that are a category we need to assign
// a 'fullSlug' for each object. It would place too much logic
// to do this on the fly within the view so instead we build
// a 'fullSlugForCategoryMap' which we can then
// access in the view by 'category.id'
get fullSlugForCategoryMap() {
const categoryMap = {};
this.args.results.forEach((result) => {
if (result.model) {
const fullSlug = result.model.parentCategory
? `#${result.model.parentCategory.slug}:${result.model.slug}`
: `#${result.model.slug}`;
categoryMap[result.model.id] = `${this.prefix}${fullSlug}`;
}
});
return categoryMap;
}
get user() {
// when only one user matches while in topic
// quick suggest user search in the topic or globally
return this.args.results[0];
}
}
export function addSearchSuggestion(value) {
if (!suggestionShortcuts.includes(value)) {
suggestionShortcuts.push(value);
}
}

View File

@ -0,0 +1,11 @@
<span class="blurb">
{{format-age @result.created_at}}
<span> - </span>
{{#if this.siteSettings.use_pg_headlines_for_excerpt}}
<span>{{@result.blurb}}</span>
{{else}}
<span>
<SearchMenu::HighlightedSearch @string={{@result.blurb}} />
</span>
{{/if}}
</span>

View File

@ -0,0 +1,7 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class Blurb extends Component {
@service siteSettings;
@service site;
}

View File

@ -0,0 +1,45 @@
<ul class="search-menu-initial-options">
{{#if this.termMatchesContextTypeKeyword}}
<SearchMenu::Results::AssistantItem
@slug={{this.slug}}
@extraHint={{true}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{this.contextTypeKeyword}}
/>
{{else}}
{{#if (or this.search.activeGlobalSearchTerm this.search.searchContext)}}
{{#if this.search.activeGlobalSearchTerm}}
<SearchMenu::Results::AssistantItem
@suffix={{i18n "search.in_topics_posts"}}
@closeSearchMenu={{@closeSearchMenu}}
@extraHint={{true}}
@searchTermChanged={{@searchTermChanged}}
@suggestionKeyword={{this.contextTypeKeyword}}
/>
{{/if}}
{{#if this.search.searchContext}}
<this.contextTypeComponent
@slug={{this.slug}}
@suggestionKeyword={{this.contextTypeKeyword}}
@results={{this.initialResults}}
@withInLabel={{this.withInLabel}}
@suffix={{this.suffix}}
@label={{this.label}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/if}}
{{else}}
<SearchMenu::Results::RandomQuickTip />
{{#if (and this.currentUser this.siteSettings.log_search_queries)}}
<SearchMenu::Results::RecentSearches
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/if}}
{{/if}}
{{/if}}
</ul>

View File

@ -0,0 +1,128 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { MODIFIER_REGEXP } from "discourse/components/search-menu";
import AssistantItem from "./assistant-item";
import Assistant from "./assistant";
import I18n from "I18n";
const SEARCH_CONTEXT_TYPE_COMPONENTS = {
topic: AssistantItem,
private_messages: AssistantItem,
category: Assistant,
tag: Assistant,
tagIntersection: Assistant,
user: AssistantItem,
};
export default class InitialOptions extends Component {
@service search;
@service siteSettings;
@service currentUser;
constructor() {
super(...arguments);
if (this.search.activeGlobalSearchTerm || this.search.searchContext) {
if (this.search.searchContext) {
// set the component we will be using to display results
this.contextTypeComponent =
SEARCH_CONTEXT_TYPE_COMPONENTS[this.search.searchContext.type];
// set attributes for the component
this.attributesForSearchContextType(this.search.searchContext.type);
}
}
}
get termMatchesContextTypeKeyword() {
return this.search.activeGlobalSearchTerm?.match(MODIFIER_REGEXP)
? true
: false;
}
attributesForSearchContextType(type) {
switch (type) {
case "topic":
this.topicContextType();
break;
case "private_messages":
this.privateMessageContextType();
break;
case "category":
this.categoryContextType();
break;
case "tag":
this.tagContextType();
break;
case "tagIntersection":
this.tagIntersectionContextType();
break;
case "user":
this.userContextType();
break;
}
}
topicContextType() {
this.suffix = I18n.t("search.in_this_topic");
}
privateMessageContextType() {
this.slug = "in:messages";
this.label = "in:messages";
}
categoryContextType() {
const searchContextCategory = this.search.searchContext.category;
const fullSlug = searchContextCategory.parentCategory
? `#${searchContextCategory.parentCategory.slug}:${searchContextCategory.slug}`
: `#${searchContextCategory.slug}`;
this.slug = fullSlug;
this.contextTypeKeyword = "#";
this.initialResults = [{ model: this.search.searchContext.category }];
this.withInLabel = true;
}
tagContextType() {
this.slug = `#${this.search.searchContext.name}`;
this.contextTypeKeyword = "#";
this.initialResults = [{ name: this.search.searchContext.name }];
this.withInLabel = true;
}
tagIntersectionContextType() {
const searchContext = this.search.searchContext;
let tagTerm;
if (searchContext.additionalTags) {
const tags = [searchContext.tagId, ...searchContext.additionalTags];
tagTerm = `tags:${tags.join("+")}`;
} else {
tagTerm = `#${searchContext.tagId}`;
}
let suggestionOptions = {
tagName: searchContext.tagId,
additionalTags: searchContext.additionalTags,
};
if (searchContext.category) {
const categorySlug = searchContext.category.parentCategory
? `#${searchContext.category.parentCategory.slug}:${searchContext.category.slug}`
: `#${searchContext.category.slug}`;
suggestionOptions.categoryName = categorySlug;
suggestionOptions.category = searchContext.category;
tagTerm = tagTerm + ` ${categorySlug}`;
}
this.slug = tagTerm;
this.contextTypeKeyword = "+";
this.initialResults = [suggestionOptions];
this.withInLabel = true;
}
userContextType() {
this.slug = `@${this.search.searchContext.user.username}`;
this.suffix = I18n.t("search.in_posts_by", {
username: this.search.searchContext.user.username,
});
}
}

View File

@ -0,0 +1,17 @@
{{#if this.topicResults}}
{{! template-lint-disable no-invalid-interactive }}
<div class="search-menu__show-more" {{on "keyup" this.onKeyup}}>
{{#if this.moreUrl}}
<a href={{this.moreUrl}} class="filter search-link">
{{i18n "more"}}...
</a>
{{else if this.topicResults.more}}
<a
{{on "click" (fn this.moreOfType this.topicResults.type)}}
class="filter search-link"
>
{{i18n "more"}}...
</a>
{{/if}}
</div>
{{/if}}

View File

@ -0,0 +1,37 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { focusSearchButton } from "discourse/components/search-menu";
export default class MoreLink extends Component {
@service search;
get topicResults() {
const topicResults = this.args.resultTypes.filter(
(resultType) => resultType.type === "topic"
);
return topicResults[0];
}
get moreUrl() {
return this.topicResults.moreUrl && this.topicResults.moreUrl();
}
@action
moreOfType(type) {
this.args.updateTypeFilter(type);
this.args.triggerSearch();
}
@action
onKeyup(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;
}
this.search.handleArrowUpOrDown(e);
}
}

View File

@ -0,0 +1,16 @@
<li class="search-random-quick-tip">
<span
class={{concat-class
"tip-label"
(if this.randomTip.clickable "tip-clickable")
}}
role="button"
{{on "click" this.tipSelected}}
>
{{this.randomTip.label}}
</span>
<span class="tip-description">
{{this.randomTip.description}}
</span>
</li>

View File

@ -0,0 +1,70 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import I18n from "I18n";
import { focusSearchInput } from "discourse/components/search-menu";
const DEFAULT_QUICK_TIPS = [
{
label: "#",
description: I18n.t("search.tips.category_tag"),
clickable: true,
},
{
label: "@",
description: I18n.t("search.tips.author"),
clickable: true,
},
{
label: "in:",
description: I18n.t("search.tips.in"),
clickable: true,
},
{
label: "status:",
description: I18n.t("search.tips.status"),
clickable: true,
},
{
label: I18n.t("search.tips.full_search_key", { modifier: "Ctrl" }),
description: I18n.t("search.tips.full_search"),
},
{
label: "@me",
description: I18n.t("search.tips.me"),
},
];
let QUICK_TIPS = [];
export function addQuickSearchRandomTip(tip) {
if (!QUICK_TIPS.includes(tip)) {
QUICK_TIPS.push(tip);
}
}
export function resetQuickSearchRandomTips() {
QUICK_TIPS = [].concat(DEFAULT_QUICK_TIPS);
}
resetQuickSearchRandomTips();
export default class RandomQuickTip extends Component {
@service search;
constructor() {
super(...arguments);
this.randomTip = QUICK_TIPS[Math.floor(Math.random() * QUICK_TIPS.length)];
}
@action
tipSelected(e) {
if (e.target.classList.contains("tip-clickable")) {
this.search.activeGlobalSearchTerm = this.randomTip.label;
focusSearchInput();
e.stopPropagation();
e.preventDefault();
}
}
}

View File

@ -0,0 +1,23 @@
{{#if this.currentUser.recent_searches}}
<div class="search-menu-recent">
<div class="heading">
<h4>{{i18n "search.recent"}}</h4>
<FlatButton
@title="search.clear_recent"
@icon="times"
@action={{this.clearRecent}}
@class="clear-recent-searches"
/>
</div>
{{#each this.currentUser.recent_searches as |slug|}}
<SearchMenu::Results::AssistantItem
@icon="history"
@label={{slug}}
@slug={{slug}}
@closeSearchMenu={{@closeSearchMenu}}
@searchTermChanged={{@searchTermChanged}}
/>
{{/each}}
</div>
{{/if}}

View File

@ -0,0 +1,51 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import User from "discourse/models/user";
import { action } from "@ember/object";
import { focusSearchButton } from "discourse/components/search-menu";
export default class RecentSearches extends Component {
@service currentUser;
@service siteSettings;
constructor() {
super(...arguments);
if (
this.currentUser &&
this.siteSettings.log_search_queries &&
!this.currentUser.recent_searches?.length
) {
this.loadRecentSearches();
}
}
@action
clearRecent() {
return User.resetRecentSearches().then((result) => {
if (result.success) {
this.currentUser.recent_searches.clear();
}
});
}
@action
onKeyup(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;
}
this.search.handleArrowUpOrDown(e);
}
loadRecentSearches() {
User.loadRecentSearches().then((result) => {
if (result.success && result.recent_searches?.length) {
this.currentUser.set("recent_searches", result.recent_searches);
}
});
}
}

View File

@ -0,0 +1 @@
{{category-link @result link=false allowUncategorized=true}}

View File

@ -0,0 +1,19 @@
<div class="group-result">
{{#if @result.flairUrl}}
<AvatarFlair
@flairName={{@result.name}}
@flairUrl={{@result.flairUrl}}
@flairBgColor={{@result.flairBgColor}}
@flairColor={{@result.flairColor}}
/>
{{else}}
{{d-icon "users"}}
{{/if}}
<div class="group-names">
<span class="name">{{or @result.fullName @result.name}}</span>
{{! show the name of the group if we also show the full name }}
{{#if @result.fullName}}
<div class="slug">{{@result.name}}</div>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,2 @@
{{i18n "search.post_format" @result}}
<SearchMenu::Results::Blurb @result={{@result}} />

View File

@ -0,0 +1,2 @@
{{d-icon "tag"}}
{{discourse-tag (or @result.id @result) tagName="span"}}

View File

@ -0,0 +1,24 @@
<span class="topic">
<span class="first-line">
<TopicStatus @topic={{@result.topic}} @disableActions={{true}} />
<span class="topic-title" data-topic-id={{@result.topic.id}}>
{{#if
(and
this.siteSettings.use_pg_headlines_for_excerpt
@result.topic_title_headline
)
}}
<span>{{replace-emoji @result.topic_title_headline}}</span>
{{else}}
<SearchMenu::HighlightedSearch @string={{@result.topic.fancyTitle}} />
{{/if}}
</span>
</span>
<span class="second-line">
{{category-link @result.topic.category link=false}}
{{#if this.siteSettings.tagging_enabled}}
{{discourse-tags @result.topic tagName="span"}}
{{/if}}
</span>
</span>
<SearchMenu::Results::Blurb @result={{@result}} />

View File

@ -0,0 +1,6 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class Results extends Component {
@service siteSettings;
}

View File

@ -0,0 +1,14 @@
{{avatar
@result
imageSize="small"
template=@result.avatar_template
username=@result.username
}}
<span class="username">
{{format-username @result.username}}
</span>
{{#if @result.custom_data}}
{{#each @result.custom_data as |row|}}
<span class="custom-field">{{row.name}}: {{row.value}}</span>
{{/each}}
{{/if}}

View File

@ -0,0 +1,18 @@
{{#each this.filteredResultTypes as |resultType|}}
<div class={{resultType.componentName}}>
<ul
class="list"
aria-label={{concat (i18n "search.results") " " resultType.type}}
>
{{#each resultType.results as |result|}}
{{! template-lint-disable no-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}
<li class="item" {{on "keydown" this.onKeydown}}>
<a href={{(or result.url result.path)}} class="search-link">
<resultType.component @result={{result}} />
</a>
</li>
{{/each}}
</ul>
</div>
{{/each}}

View File

@ -0,0 +1,35 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { focusSearchButton } from "discourse/components/search-menu";
export default class Types extends Component {
@service search;
get filteredResultTypes() {
// return only topic result types
if (this.args.topicResultsOnly) {
return this.args.resultTypes.filter(
(resultType) => resultType.type === "topic"
);
}
// return all result types minus topics
return this.args.resultTypes.filter(
(resultType) => resultType.type !== "topic"
);
}
@action
onKeydown(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;
}
this.search.handleResultInsertion(e);
this.search.handleArrowUpOrDown(e);
}
}

View File

@ -0,0 +1,11 @@
<input
id={{this.inputId}}
type="text"
autocomplete="off"
value={{this.search.activeGlobalSearchTerm}}
placeholder={{i18n "search.title"}}
aria-label={{i18n "search.title"}}
{{on "keyup" this.onKeyup}}
{{on "input" this.updateSearchTerm}}
{{did-insert this.focus}}
/>

View File

@ -0,0 +1,89 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { isiPad } from "discourse/lib/utilities";
import { inject as service } from "@ember/service";
import {
DEFAULT_TYPE_FILTER,
SEARCH_INPUT_ID,
focusSearchButton,
} from "discourse/components/search-menu";
const SECOND_ENTER_MAX_DELAY = 15000;
export default class SearchTerm extends Component {
@service search;
@service appEvents;
@tracked lastEnterTimestamp = null;
// make constant available in template
get inputId() {
return SEARCH_INPUT_ID;
}
@action
updateSearchTerm(input) {
this.parseAndUpdateSearchTerm(
this.search.activeGlobalSearchTerm,
input.target.value
);
}
@action
focus(element) {
element.focus();
element.select();
}
@action
onKeyup(e) {
if (e.key === "Escape") {
focusSearchButton();
this.args.closeSearchMenu();
e.preventDefault();
return false;
}
this.search.handleArrowUpOrDown(e);
if (e.key === "Enter") {
const recentEnterHit =
this.lastEnterTimestamp &&
Date.now() - this.lastEnterTimestamp < SECOND_ENTER_MAX_DELAY;
// same combination as key-enter-escape mixin
if (
e.ctrlKey ||
e.metaKey ||
(isiPad() && e.altKey) ||
(this.args.typeFilter !== DEFAULT_TYPE_FILTER && recentEnterHit)
) {
this.args.fullSearch();
this.args.closeSearchMenu();
} else {
this.args.updateTypeFilter(null);
this.args.triggerSearch();
}
this.lastEnterTimestamp = Date.now();
}
if (e.key === "Backspace") {
if (!e.target.value) {
this.args.clearTopicContext();
this.args.clearPMInboxContext();
this.focus(e.target);
}
}
e.preventDefault();
}
parseAndUpdateSearchTerm(originalVal, newVal) {
// remove zero-width chars
const parsedVal = newVal.replace(/[\u200B-\u200D\uFEFF]/, "");
if (parsedVal !== originalVal) {
this.args.searchTermChanged(parsedVal);
}
}
}

View File

@ -100,6 +100,7 @@ import {
addSearchSuggestion,
removeDefaultQuickSearchRandomTips,
} from "discourse/widgets/search-menu-results";
import { addSearchSuggestion as addGlimmerSearchSuggestion } from "discourse/components/search-menu/results/assistant";
import { CUSTOM_USER_SEARCH_OPTIONS } from "select-kit/components/user-chooser";
import { downloadCalendar } from "discourse/lib/download-calendar";
import { consolePrefix } from "discourse/lib/source-identifier";
@ -1667,6 +1668,7 @@ class PluginApi {
*/
addSearchSuggestion(value) {
addSearchSuggestion(value);
addGlimmerSearchSuggestion(value);
}
/**

View File

@ -108,11 +108,13 @@ function translateGroupedSearchResults(results, opts) {
const groupedSearchResult = results.grouped_search_result;
if (groupedSearchResult) {
[
// We are defining the order that the result types will be
// displayed in. We should make this customizable.
["topic", "posts"],
["user", "users"],
["group", "groups"],
["category", "categories"],
["tag", "tags"],
["user", "users"],
["group", "groups"],
].forEach(function (pair) {
const type = pair[0];
const name = pair[1];

View File

@ -206,7 +206,7 @@ export default (filterArg, params) => {
}
this.controllerFor("discovery/topics").setProperties(topicOpts);
this.searchService.set("searchContext", category.get("searchContext"));
this.searchService.searchContext = category.get("searchContext");
this.set("topics", null);
},
@ -231,7 +231,7 @@ export default (filterArg, params) => {
this._super(...arguments);
this.composer.set("prioritizedCategoryId", null);
this.searchService.set("searchContext", null);
this.searchService.searchContext = null;
},
@action

View File

@ -39,12 +39,11 @@ export default (type) => {
showPosters: true,
});
const currentUser = this.currentUser;
this.searchService.set("searchContext", {
this.searchService.searchContext = {
type: "private_messages",
id: currentUser.get("username_lower"),
user: currentUser,
});
id: this.currentUser.get("username_lower"),
user: this.currentUser,
};
},
emptyState() {
@ -59,7 +58,7 @@ export default (type) => {
},
deactivate() {
this.searchService.set("searchContext", null);
this.searchService.searchContext = null;
},
});
};

View File

@ -80,7 +80,16 @@ export default (inboxType, path, filter) => {
group: null,
});
this.searchService.set("contextType", "private_messages");
// Private messages don't have a unique search context instead
// it is built upon the user search context and then tweaks the `type`.
// Since this is the only model in which we set a custom `type` we don't
// want to create a stand-alone `setSearchType` on the search service so
// we can instead explicitly set the search context and pass in the `type`
const pmSearchContext = {
...this.controllerFor("user").get("model.searchContext"),
type: "private_messages",
};
this.searchService.searchContext = pmSearchContext;
},
emptyState() {
@ -97,9 +106,8 @@ export default (inboxType, path, filter) => {
deactivate() {
this.controllerFor("user-topics-list").unsubscribe();
this.searchService.set(
"searchContext",
this.controllerFor("user").get("model.searchContext")
this.searchService.searchContext = this.controllerFor("user").get(
"model.searchContext"
);
},

View File

@ -161,9 +161,9 @@ export default DiscourseRoute.extend(FilterModeMixin, {
category: model.category || null,
};
this.searchService.set("searchContext", tagIntersectionSearchContext);
this.searchService.searchContext = tagIntersectionSearchContext;
} else {
this.searchService.set("searchContext", model.tag.searchContext);
this.searchService.searchContext = model.tag.searchContext;
}
},
@ -202,7 +202,7 @@ export default DiscourseRoute.extend(FilterModeMixin, {
deactivate() {
this._super(...arguments);
this.searchService.set("searchContext", null);
this.searchService.searchContext = null;
},
@action

View File

@ -325,7 +325,7 @@ const TopicRoute = DiscourseRoute.extend({
deactivate() {
this._super(...arguments);
this.searchService.set("searchContext", null);
this.searchService.searchContext = null;
const topicController = this.controllerFor("topic");
const postStream = topicController.get("model.postStream");
@ -351,7 +351,7 @@ const TopicRoute = DiscourseRoute.extend({
firstPostExpanded: false,
});
this.searchService.set("searchContext", model.get("searchContext"));
this.searchService.searchContext = model.get("searchContext");
// close the multi select when switching topics
controller.set("multiSelect", false);

View File

@ -44,7 +44,7 @@ export default DiscourseRoute.extend({
setupController(controller, user) {
controller.set("model", user);
this.searchService.set("searchContext", user.searchContext);
this.searchService.searchContext = user.searchContext;
},
activate() {
@ -73,7 +73,7 @@ export default DiscourseRoute.extend({
user.stopTrackingStatus();
// Remove the search context
this.searchService.set("searchContext", null);
this.searchService.searchContext = null;
},
@bind

View File

@ -1,21 +1,109 @@
import Service from "@ember/service";
import discourseComputed from "discourse-common/utils/decorators";
import Service, { inject as service } from "@ember/service";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { focusSearchInput } from "discourse/components/search-menu";
export default Service.extend({
searchContextEnabled: false, // checkbox to scope search
searchContext: null,
highlightTerm: null,
@disableImplicitInjections
export default class Search extends Service {
@service appEvents;
@discourseComputed("searchContext")
contextType: {
get(searchContext) {
return searchContext?.type;
},
@tracked activeGlobalSearchTerm = "";
@tracked searchContext;
@tracked highlightTerm;
set(value, searchContext) {
this.set("searchContext", { ...searchContext, type: value });
// only relative for the widget search menu
searchContextEnabled = false; // checkbox to scope search
return value;
},
},
});
get contextType() {
return this.searchContext?.type || null;
}
// The need to navigate with the keyboard creates a lot shared logic
// between multiple components
//
// - SearchTerm
// - Results::AssistantItem
// - Results::Types
// - Results::MoreLink
// - Results::RecentSearches
//
// To minimze the duplicate logic we will create a shared action here
// that can be reused across all of the components
@action
handleResultInsertion(e) {
if (e.keyCode === 65 /* a or A */) {
// add a link and focus composer if open
if (document.querySelector("#reply-control.open")) {
this.appEvents.trigger(
"composer:insert-text",
document.activeElement.href,
{
ensureSpace: true,
}
);
this.appEvents.trigger("header:keyboard-trigger", { type: "search" });
document.querySelector("#reply-control.open textarea").focus();
e.stopPropagation();
e.preventDefault();
return false;
}
}
}
@action
handleArrowUpOrDown(e) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
let focused = e.target.closest(".search-menu") ? e.target : null;
if (!focused) {
return;
}
let links = document.querySelectorAll(".search-menu .results a");
let results = document.querySelectorAll(
".search-menu .results .search-link"
);
if (!results.length) {
return;
}
let prevResult;
let result;
links.forEach((item) => {
if (item.classList.contains("search-link")) {
prevResult = item;
}
if (item === focused) {
result = prevResult;
}
});
let index = -1;
if (result) {
index = Array.prototype.indexOf.call(results, result);
}
if (index === -1 && e.key === "ArrowDown") {
// change focus from the search input to the first result item
const firstResult = results[0] || links[0];
firstResult.focus();
} else if (index === 0 && e.key === "ArrowUp") {
focusSearchInput();
} else if (index > -1) {
// change focus to the next result item if present
index += e.key === "ArrowDown" ? 1 : -1;
if (index >= 0 && index < results.length) {
results[index].focus();
}
}
e.stopPropagation();
e.preventDefault();
return false;
}
}
}

View File

@ -13,6 +13,7 @@ import { logSearchLinkClick } from "discourse/lib/search";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
import { hideUserTip } from "discourse/lib/user-tips";
import { SEARCH_BUTTON_ID } from "discourse/components/search-menu";
let _extraHeaderIcons = [];
@ -266,7 +267,7 @@ createWidget("header-icons", {
const search = this.attach("header-dropdown", {
title: "search.title",
icon: "search",
iconId: "search-button",
iconId: SEARCH_BUTTON_ID,
action: "toggleSearchMenu",
active: attrs.searchVisible,
href: getURL("/search"),
@ -423,6 +424,45 @@ createWidget("revamped-user-menu-wrapper", {
},
});
createWidget("glimmer-search-menu-wrapper", {
buildAttributes() {
return { "data-click-outside": true, "aria-live": "polite" };
},
buildClasses() {
return ["search-menu"];
},
html() {
return [
new RenderGlimmer(
this,
"div.widget-component-connector",
hbs`<SearchMenu
@inTopicContext={{@data.inTopicContext}}
@searchVisible={{@data.searchVisible}}
@animationClass={{@data.animationClass}}
@closeSearchMenu={{@data.closeSearchMenu}}
/>`,
{
closeSearchMenu: this.closeSearchMenu.bind(this),
inTopicContext: this.attrs.inTopicContext,
searchVisible: this.attrs.searchVisible,
animationClass: this.attrs.animationClass,
}
),
];
},
closeSearchMenu() {
this.sendWidgetAction("toggleSearchMenu");
},
clickOutside() {
this.closeSearchMenu();
},
});
export default createWidget("header", {
tagName: "header.d-header.clearfix",
buildKey: () => `header`,
@ -467,11 +507,21 @@ export default createWidget("header", {
const panels = [this.attach("header-buttons", attrs), headerIcons];
if (state.searchVisible) {
panels.push(
this.attach("search-menu", {
inTopicContext: state.inTopicContext && inTopicRoute,
})
);
if (this.currentUser?.experimental_search_menu_groups_enabled) {
panels.push(
this.attach("glimmer-search-menu-wrapper", {
inTopicContext: state.inTopicContext && inTopicRoute,
searchVisible: state.searchVisible,
animationClass: this.animationClass(),
})
);
} else {
panels.push(
this.attach("search-menu", {
inTopicContext: state.inTopicContext && inTopicRoute,
})
);
}
} else if (state.hamburgerVisible) {
if (
attrs.navigationMenuQueryParamOverride === "header_dropdown" ||
@ -522,6 +572,12 @@ export default createWidget("header", {
}
},
animationClass() {
return this.site.mobileView || this.site.narrowDesktopView
? "slide-in"
: "drop-down";
},
closeAll() {
this.state.userVisible = false;
this.state.hamburgerVisible = false;
@ -712,7 +768,12 @@ export default createWidget("header", {
},
focusSearchInput() {
if (this.state.searchVisible) {
// the glimmer search menu handles the focusing of the search
// input within the search component
if (
this.state.searchVisible &&
!this.currentUser?.experimental_search_menu_groups_enabled
) {
schedule("afterRender", () => {
const searchInput = document.querySelector("#search-term");
searchInput.focus();

View File

@ -0,0 +1,53 @@
import {
acceptance,
count,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("Search - Glimmer - Mobile", function (needs) {
needs.mobileView();
needs.user({
experimental_search_menu_groups_enabled: true,
});
test("search", async function (assert) {
await visit("/");
await click("#search-button");
assert.ok(
exists("input.full-page-search"),
"it shows the full page search form"
);
assert.ok(!exists(".search-results .fps-topic"), "no results by default");
await click(".advanced-filters summary");
assert.ok(
exists(".advanced-filters[open]"),
"it should expand advanced search filters"
);
await fillIn(".search-query", "discourse");
await click(".search-cta");
assert.strictEqual(count(".fps-topic"), 1, "has one post");
assert.notOk(
exists(".advanced-filters[open]"),
"it should collapse advanced search filters"
);
await click("#search-button");
assert.strictEqual(
query("input.full-page-search").value,
"discourse",
"it does not reset input when hitting search icon again"
);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -709,6 +709,111 @@ export default {
"/letter_avatar/devmach/{size}/5_fcf819f9b3791cb8c87edf29c8984f83.png",
},
],
"/tag/important/notifications.json": {
users: [{ id: 1, username: "sam", avatar_template: "/images/avatar.png" }],
primary_groups: [],
topic_list: {
can_create_topic: true,
draft: null,
draft_key: "new_topic",
draft_sequence: 4,
per_page: 30,
tags: [
{
id: 1,
name: "important",
topic_count: 2,
staff: false,
},
],
topics: [
{
id: 16,
title: "Dinosaurs are the best",
fancy_title: "Dinosaurs are the best",
slug: "dinosaurs-are-the-best",
posts_count: 1,
reply_count: 0,
highest_post_number: 1,
image_url: null,
created_at: "2019-11-12T05:19:52.300Z",
last_posted_at: "2019-11-12T05:19:52.848Z",
bumped: true,
bumped_at: "2019-11-12T05:19:52.848Z",
unseen: false,
last_read_post_number: 1,
unread_posts: 0,
pinned: false,
unpinned: null,
visible: true,
closed: false,
archived: false,
notification_level: 3,
bookmarked: false,
liked: false,
tags: ["test"],
views: 2,
like_count: 0,
has_summary: false,
archetype: "regular",
last_poster_username: "sam",
category_id: 1,
pinned_globally: false,
featured_link: null,
posters: [
{
extras: "latest single",
description: "Original Poster, Most Recent Poster",
user_id: 1,
primary_group_id: null,
},
],
},
{
id: 15,
title: "This is a test tagged post",
fancy_title: "This is a test tagged post",
slug: "this-is-a-test-tagged-post",
posts_count: 1,
reply_count: 0,
highest_post_number: 1,
image_url: null,
created_at: "2019-11-12T05:19:32.032Z",
last_posted_at: "2019-11-12T05:19:32.516Z",
bumped: true,
bumped_at: "2019-11-12T05:19:32.516Z",
unseen: false,
last_read_post_number: 1,
unread_posts: 0,
pinned: false,
unpinned: null,
visible: true,
closed: false,
archived: false,
notification_level: 3,
bookmarked: false,
liked: false,
tags: ["test"],
views: 1,
like_count: 0,
has_summary: false,
archetype: "regular",
last_poster_username: "sam",
category_id: 3,
pinned_globally: false,
featured_link: null,
posters: [
{
extras: "latest single",
description: "Original Poster, Most Recent Poster",
user_id: 1,
primary_group_id: null,
},
],
},
],
},
},
categories: [
{
id: 7,

View File

@ -257,11 +257,17 @@
}
.hamburger-panel {
// remove once glimmer search menu in place
a.widget-link {
width: 100%;
box-sizing: border-box;
@include ellipsis;
}
a.search-link {
width: 100%;
box-sizing: border-box;
@include ellipsis;
}
.panel-body {
overflow-y: auto;
}
@ -279,6 +285,7 @@
}
.menu-panel {
// remove once glimmer search menu in place
.widget-link,
.categories-link {
padding: 0.25em 0.5em;
@ -306,6 +313,33 @@
}
}
.search-link,
.categories-link {
padding: 0.25em 0.5em;
display: block;
color: var(--primary);
&:hover,
&:focus {
background-color: var(--d-hover);
outline: none;
}
.d-icon {
color: var(--primary-medium);
}
.new {
font-size: var(--font-down-1);
margin-left: 0.5em;
color: var(--primary-med-or-secondary-med);
}
&.show-help,
&.filter {
color: var(--tertiary);
}
}
li.category-link {
float: left;
background-color: transparent;

View File

@ -1831,6 +1831,10 @@ class User < ActiveRecord::Base
in_any_groups?(SiteSetting.new_edit_sidebar_categories_tags_interface_groups_map)
end
def experimental_search_menu_groups_enabled?
in_any_groups?(SiteSetting.experimental_search_menu_groups_map)
end
protected
def badge_grant

View File

@ -69,7 +69,8 @@ class CurrentUserSerializer < BasicUserSerializer
:sidebar_list_destination,
:sidebar_sections,
:new_new_view_enabled?,
:new_edit_sidebar_categories_tags_interface_groups_enabled?
:new_edit_sidebar_categories_tags_interface_groups_enabled?,
:experimental_search_menu_groups_enabled?
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat

View File

@ -2431,6 +2431,7 @@ en:
enable_custom_sidebar_sections: "EXPERIMENTAL: Enable custom sidebar sections"
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
experimental_post_image_grid: "EXPERIMENTAL: Enables a [grid] tag in posts to display images in a grid layout."
experimental_search_menu_groups: "EXPERIMENTAL: Enables the new search menu that has been upgraded to use glimmer"
errors:
invalid_css_color: "Invalid color. Enter a color name or hex value."

View File

@ -2121,6 +2121,12 @@ developer:
default: ""
allow_any: false
hidden: true
experimental_search_menu_groups:
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
navigation:
navigation_menu: