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:
parent
251d6f0627
commit
a2b038ffe7
|
@ -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>
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<a
|
||||
class="show-advanced-search"
|
||||
title={{i18n "search.open_advanced"}}
|
||||
href={{@href}}
|
||||
>
|
||||
{{d-icon "sliders-h"}}
|
||||
</a>
|
|
@ -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>
|
|
@ -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+") });
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
{{this.content}}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
{{category-link @result link=false allowUncategorized=true}}
|
|
@ -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>
|
|
@ -0,0 +1,2 @@
|
|||
{{i18n "search.post_format" @result}}
|
||||
<SearchMenu::Results::Blurb @result={{@result}} />
|
|
@ -0,0 +1,2 @@
|
|||
{{d-icon "tag"}}
|
||||
{{discourse-tag (or @result.id @result) tagName="span"}}
|
|
@ -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}} />
|
|
@ -0,0 +1,6 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class Results extends Component {
|
||||
@service siteSettings;
|
||||
}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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}}
|
||||
/>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
},
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue