chore: recover local search component (#4104)

This commit is contained in:
Sami Mazouz 2024-11-08 17:22:12 +01:00 committed by GitHub
parent 04fe684db8
commit 8c331038da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 695 additions and 122 deletions

View File

@ -2,7 +2,7 @@ import type Mithril from 'mithril';
import app from '../app';
import highlight from '../../common/helpers/highlight';
import type { SearchSource } from './Search';
import type { GlobalSearchSource } from './GlobalSearch';
import extractText from '../../common/utils/extractText';
import Link from '../../common/components/Link';
import Icon from '../../common/components/Icon';
@ -26,7 +26,7 @@ export class GeneralSearchResult {
/**
* Finds and displays settings, permissions and installed extensions (i.e. general search results) in the search dropdown.
*/
export default class GeneralSearchSource implements SearchSource {
export default class GeneralSearchSource implements GlobalSearchSource {
protected results = new Map<string, GeneralSearchResult[]>();
public resource: string = 'general';

View File

@ -1,18 +1,21 @@
import ItemList from '../../common/utils/ItemList';
import AbstractSearch, { type SearchAttrs, type SearchSource as BaseSearchSource } from '../../common/components/AbstractSearch';
import AbstractGlobalSearch, {
type SearchAttrs,
type GlobalSearchSource as BaseGlobalSearchSource,
} from '../../common/components/AbstractGlobalSearch';
import GeneralSearchSource from './GeneralSearchSource';
import app from '../app';
export interface SearchSource extends BaseSearchSource {}
export interface GlobalSearchSource extends BaseGlobalSearchSource {}
export default class Search extends AbstractSearch {
export default class GlobalSearch extends AbstractGlobalSearch {
static initAttrs(attrs: SearchAttrs) {
attrs.label = app.translator.trans('core.admin.header.search_placeholder', {}, true);
attrs.a11yRoleLabel = app.translator.trans('core.admin.header.search_role_label', {}, true);
}
sourceItems(): ItemList<SearchSource> {
const items = new ItemList<SearchSource>();
sourceItems(): ItemList<GlobalSearchSource> {
const items = new ItemList<GlobalSearchSource>();
items.add('general', new GeneralSearchSource());

View File

@ -5,7 +5,7 @@ import SessionDropdown from './SessionDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import type Mithril from 'mithril';
import Search from './Search';
import GlobalSearch from './GlobalSearch';
/**
* The `HeaderSecondary` component displays secondary header controls.
@ -21,7 +21,7 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList<Mithril.Children>();
items.add('search', <Search state={app.search.state} />, 30);
items.add('search', <GlobalSearch state={app.search.state} />, 30);
items.add(
'help',

View File

@ -6,7 +6,7 @@ import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import LoadingModal from './LoadingModal';
import LinkButton from '../../common/components/LinkButton';
import saveSettings from '../utils/saveSettings.js';
import saveSettings from '../utils/saveSettings';
export default class StatusWidget extends DashboardWidget {
className() {

View File

@ -14,16 +14,16 @@ export interface SearchAttrs extends ComponentAttrs {
}
/**
* The `SearchSource` interface defines a section of search results in the
* search dropdown.
* The `SearchSource` interface defines a tab of search results in the
* search modal.
*
* Search sources should be registered with the `Search` component class
* Search sources should be registered with the `GlobalSearch` component class
* by extending the `sourceItems` method. When the user types a
* query, each search source will be prompted to load search results via the
* `search` method. When the dropdown is redrawn, it will be constructed by
* `search` method. When the search modal's dropdown is redrawn, it will be constructed by
* putting together the output from the `view` method of each source.
*/
export interface SearchSource {
export interface GlobalSearchSource {
/**
* The resource type that this search source is responsible for.
*/
@ -74,7 +74,7 @@ export interface SearchSource {
*
* Must be extended and the abstract methods implemented per-frontend.
*/
export default abstract class AbstractSearch<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
export default abstract class AbstractGlobalSearch<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
/**
* The instance of `SearchState` for this component.
*/
@ -136,5 +136,5 @@ export default abstract class AbstractSearch<T extends SearchAttrs = SearchAttrs
/**
* A list of search sources that can be used to query for search results.
*/
abstract sourceItems(): ItemList<SearchSource>;
abstract sourceItems(): ItemList<GlobalSearchSource>;
}

View File

@ -14,12 +14,12 @@ import LoadingIndicator from './LoadingIndicator';
import type IGambit from '../query/IGambit';
import ItemList from '../utils/ItemList';
import GambitsAutocomplete from '../utils/GambitsAutocomplete';
import type { SearchSource } from './AbstractSearch';
import type { GlobalSearchSource } from './AbstractGlobalSearch';
export interface ISearchModalAttrs extends IFormModalAttrs {
onchange: (value: string) => void;
searchState: SearchState;
sources: SearchSource[];
sources: GlobalSearchSource[];
}
export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearchModalAttrs> extends FormModal<CustomAttrs> {
@ -32,12 +32,12 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
/**
* An array of SearchSources.
*/
protected sources!: SearchSource[];
protected sources!: GlobalSearchSource[];
/**
* The key of the currently-active search source.
*/
protected activeSource!: Stream<SearchSource>;
protected activeSource!: Stream<GlobalSearchSource>;
/**
* The sources that are still loading results.
@ -214,7 +214,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
return items;
}
switchSource(source: SearchSource) {
switchSource(source: GlobalSearchSource) {
if (this.activeSource() !== source) {
this.activeSource(source);
this.search(this.query());

View File

@ -0,0 +1,61 @@
import app from '../../forum/app';
import Component, { ComponentAttrs } from '../../common/Component';
import Link from '../../common/components/Link';
import highlight from '../../common/helpers/highlight';
import Discussion from '../../common/models/Discussion';
import Post from '../../common/models/Post';
import type Mithril from 'mithril';
import ItemList from '../../common/utils/ItemList';
export interface DiscussionsSearchItemAttrs extends ComponentAttrs {
query: string;
discussion: Discussion;
mostRelevantPost: Post;
}
export default class DiscussionsSearchItem extends Component<DiscussionsSearchItemAttrs> {
query!: string;
discussion!: Discussion;
mostRelevantPost!: Post | null | undefined;
oninit(vnode: Mithril.Vnode<DiscussionsSearchItemAttrs, this>) {
super.oninit(vnode);
this.query = this.attrs.query;
this.discussion = this.attrs.discussion;
this.mostRelevantPost = this.attrs.mostRelevantPost;
}
view() {
return (
<li className="DiscussionSearchResult" data-index={'discussions' + this.discussion.id()}>
<Link href={app.route.discussion(this.discussion, (this.mostRelevantPost && this.mostRelevantPost.number()) || 0)}>
{this.viewItems().toArray()}
</Link>
</li>
);
}
discussionTitle() {
return this.discussion.title();
}
mostRelevantPostContent() {
return this.mostRelevantPost?.contentPlain();
}
viewItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('discussion-title', <div className="DiscussionSearchResult-title">{highlight(this.discussionTitle(), this.query)}</div>, 90);
!!this.mostRelevantPost &&
items.add(
'most-relevant',
<div className="DiscussionSearchResult-excerpt">{highlight(this.mostRelevantPostContent() ?? '', this.query, 100)}</div>,
80
);
return items;
}
}

View File

@ -1,10 +1,9 @@
import app from '../app';
import app from '../../forum/app';
import LinkButton from '../../common/components/LinkButton';
import { SearchSource } from './Search';
import type Mithril from 'mithril';
import type Discussion from '../../common/models/Discussion';
import type { SearchSource } from './Search';
import extractText from '../../common/utils/extractText';
import MinimalDiscussionListItem from './MinimalDiscussionListItem';
import Discussion from '../../common/models/Discussion';
import DiscussionsSearchItem from './DiscussionsSearchItem';
/**
* The `DiscussionsSearchSource` finds and displays discussion search results in
@ -12,26 +11,19 @@ import MinimalDiscussionListItem from './MinimalDiscussionListItem';
*/
export default class DiscussionsSearchSource implements SearchSource {
protected results = new Map<string, Discussion[]>();
queryString: string | null = null;
public resource: string = 'discussions';
title(): string {
return extractText(app.translator.trans('core.lib.search_source.discussions.heading'));
}
isCached(query: string): boolean {
return this.results.has(query.toLowerCase());
}
async search(query: string, limit: number): Promise<void> {
async search(query: string): Promise<void> {
query = query.toLowerCase();
this.results.set(query, []);
this.setQueryString(query);
const params = {
filter: { q: query },
page: { limit },
include: 'mostRelevantPost,user,firstPost,tags',
filter: { q: this.queryString || query },
page: { limit: this.limit() },
include: this.includes().join(','),
};
return app.store.find<Discussion[]>('discussions', params).then((results) => {
@ -43,38 +35,38 @@ export default class DiscussionsSearchSource implements SearchSource {
view(query: string): Array<Mithril.Vnode> {
query = query.toLowerCase();
return (this.results.get(query) || []).map((discussion) => {
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()} data-id={discussion.id()}>
<MinimalDiscussionListItem discussion={discussion} params={{ q: query }} />
</li>
);
this.setQueryString(query);
const results = (this.results.get(query) || []).map((discussion) => {
const mostRelevantPost = discussion.mostRelevantPost();
return <DiscussionsSearchItem query={query} discussion={discussion} mostRelevantPost={mostRelevantPost} />;
}) as Array<Mithril.Vnode>;
}
customGrouping(): boolean {
return false;
}
fullPage(query: string): Mithril.Vnode {
const filter = app.search.gambits.apply('discussions', { q: query });
const q = filter.q || null;
delete filter.q;
return (
return [
<li className="Dropdown-header">{app.translator.trans('core.lib.search_source.discussions.heading')}</li>,
<li>
<LinkButton icon="fas fa-search" href={app.route('index', { q, filter })}>
<LinkButton icon="fas fa-search" href={app.route('index', { q: this.queryString })}>
{app.translator.trans('core.lib.search_source.discussions.all_button', { query })}
</LinkButton>
</li>
);
</li>,
...results,
];
}
gotoItem(id: string): string | null {
const discussion = app.store.getById<Discussion>('discussions', id);
includes(): string[] {
return ['mostRelevantPost'];
}
if (!discussion) return null;
limit(): number {
return 3;
}
return app.route.discussion(discussion);
queryMutators(): string[] {
return [];
}
setQueryString(query: string): void {
this.queryString = query + ' ' + this.queryMutators().join(' ');
}
}

View File

@ -0,0 +1,80 @@
import app from '../app';
import LinkButton from '../../common/components/LinkButton';
import type Mithril from 'mithril';
import type Discussion from '../../common/models/Discussion';
import type { GlobalSearchSource } from './GlobalSearch';
import extractText from '../../common/utils/extractText';
import MinimalDiscussionListItem from './MinimalDiscussionListItem';
/**
* The `DiscussionsSearchSource` finds and displays discussion search results in
* the search dropdown.
*/
export default class GlobalDiscussionsSearchSource implements GlobalSearchSource {
protected results = new Map<string, Discussion[]>();
public resource: string = 'discussions';
title(): string {
return extractText(app.translator.trans('core.lib.search_source.discussions.heading'));
}
isCached(query: string): boolean {
return this.results.has(query.toLowerCase());
}
async search(query: string, limit: number): Promise<void> {
query = query.toLowerCase();
this.results.set(query, []);
const params = {
filter: { q: query },
page: { limit },
include: 'mostRelevantPost,user,firstPost,tags',
};
return app.store.find<Discussion[]>('discussions', params).then((results) => {
this.results.set(query, results);
m.redraw();
});
}
view(query: string): Array<Mithril.Vnode> {
query = query.toLowerCase();
return (this.results.get(query) || []).map((discussion) => {
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()} data-id={discussion.id()}>
<MinimalDiscussionListItem discussion={discussion} params={{ q: query }} />
</li>
);
}) as Array<Mithril.Vnode>;
}
customGrouping(): boolean {
return false;
}
fullPage(query: string): Mithril.Vnode {
const filter = app.search.gambits.apply('discussions', { q: query });
const q = filter.q || null;
delete filter.q;
return (
<li>
<LinkButton icon="fas fa-search" href={app.route('index', { q, filter })}>
{app.translator.trans('core.lib.search_source.discussions.all_button', { query })}
</LinkButton>
</li>
);
}
gotoItem(id: string): string | null {
const discussion = app.store.getById<Discussion>('discussions', id);
if (!discussion) return null;
return app.route.discussion(discussion);
}
}

View File

@ -2,7 +2,7 @@ import app from '../app';
import LinkButton from '../../common/components/LinkButton';
import type Mithril from 'mithril';
import type Post from '../../common/models/Post';
import type { SearchSource } from './Search';
import type { GlobalSearchSource } from './GlobalSearch';
import extractText from '../../common/utils/extractText';
import MinimalDiscussionListItem from './MinimalDiscussionListItem';
@ -10,7 +10,7 @@ import MinimalDiscussionListItem from './MinimalDiscussionListItem';
* The `PostsSearchSource` finds and displays post search results in
* the search dropdown.
*/
export default class PostsSearchSource implements SearchSource {
export default class GlobalPostsSearchSource implements GlobalSearchSource {
protected results = new Map<string, Post[]>();
public resource: string = 'posts';

View File

@ -0,0 +1,35 @@
import app from '../../forum/app';
import ItemList from '../../common/utils/ItemList';
import GlobalDiscussionsSearchSource from './GlobalDiscussionsSearchSource';
import GlobalUsersSearchSource from './GlobalUsersSearchSource';
import GlobalPostsSearchSource from './GlobalPostsSearchSource';
import AbstractGlobalSearch, {
type SearchAttrs as BaseSearchAttrs,
type GlobalSearchSource as BaseGlobalSearchSource,
} from '../../common/components/AbstractGlobalSearch';
export interface GlobalSearchSource extends BaseGlobalSearchSource {}
export interface SearchAttrs extends BaseSearchAttrs {}
export default class GlobalSearch<Attrs extends SearchAttrs = SearchAttrs> extends AbstractGlobalSearch<Attrs> {
static initAttrs(attrs: SearchAttrs) {
attrs.label = app.translator.trans('core.forum.header.search_placeholder', {}, true);
attrs.a11yRoleLabel = app.translator.trans('core.forum.header.search_role_label', {}, true);
}
sourceItems(): ItemList<GlobalSearchSource> {
const items = new ItemList<GlobalSearchSource>();
if (app.forum.attribute('canViewForum')) {
items.add('discussions', new GlobalDiscussionsSearchSource());
items.add('posts', new GlobalPostsSearchSource());
}
if (app.forum.attribute('canSearchUsers')) {
items.add('users', new GlobalUsersSearchSource());
}
return items;
}
}

View File

@ -0,0 +1,70 @@
import type Mithril from 'mithril';
import app from '../app';
import type User from '../../common/models/User';
import type { GlobalSearchSource } from './GlobalSearch';
import extractText from '../../common/utils/extractText';
import UserSearchResult from '../../common/components/UserSearchResult';
/**
* The `UsersSearchSource` finds and displays user search results in the search
* dropdown.
*/
export default class GlobalUsersSearchSource implements GlobalSearchSource {
protected results = new Map<string, User[]>();
public resource: string = 'users';
title(): string {
return extractText(app.translator.trans('core.lib.search_source.users.heading'));
}
isCached(query: string): boolean {
return this.results.has(query.toLowerCase());
}
async search(query: string, limit: number): Promise<void> {
return app.store
.find<User[]>('users', {
filter: { q: query },
page: { limit },
})
.then((results) => {
this.results.set(query, results);
m.redraw();
});
}
view(query: string): Array<Mithril.Vnode> {
query = query.toLowerCase();
const results = (this.results.get(query) || [])
.concat(
app.store
.all<User>('users')
.filter((user) => [user.username(), user.displayName()].some((value) => value.toLowerCase().substr(0, query.length) === query))
)
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
.sort((a, b) => a.displayName().localeCompare(b.displayName()));
if (!results.length) return [];
return results.map((user) => <UserSearchResult user={user} query={query} />);
}
customGrouping(): boolean {
return false;
}
fullPage(query: string): null {
return null;
}
gotoItem(id: string): string | null {
const user = app.store.getById<User>('users', id);
if (!user) return null;
return app.route.user(user);
}
}

View File

@ -6,7 +6,7 @@ import SelectDropdown from '../../common/components/SelectDropdown';
import NotificationsDropdown from './NotificationsDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Search from '../components/Search';
import GlobalSearch from './GlobalSearch';
/**
* The `HeaderSecondary` component displays secondary header controls, such as
@ -26,7 +26,7 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList();
items.add('search', <Search state={app.search.state} />, 30);
items.add('search', <GlobalSearch state={app.search.state} />, 30);
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
const locales = [];

View File

@ -1,30 +1,371 @@
import app from '../../forum/app';
import Component, { ComponentAttrs } from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable';
import Icon from '../../common/components/Icon';
import SearchState from '../../common/states/SearchState';
import DiscussionsSearchSource from './DiscussionsSearchSource';
import UsersSearchSource from './UsersSearchSource';
import PostsSearchSource from './PostsSearchSource';
import AbstractSearch, { type SearchAttrs, type SearchSource as BaseSearchSource } from '../../common/components/AbstractSearch';
import type Mithril from 'mithril';
export interface SearchSource extends BaseSearchSource {}
/**
* The `SearchSource` interface defines a section of search results in the
* search dropdown.
*
* Search sources should be registered with the `Search` component class
* by extending the `sourceItems` method. When the user types a
* query, each search source will be prompted to load search results via the
* `search` method. When the dropdown is redrawn, it will be constructed by
* putting together the output from the `view` method of each source.
*/
export interface SearchSource {
/**
* Make a request to get results for the given query.
* The results will be updated internally in the search source, not exposed.
*/
search(query: string): Promise<void>;
export default class Search extends AbstractSearch {
static initAttrs(attrs: SearchAttrs) {
attrs.label = app.translator.trans('core.forum.header.search_placeholder', {}, true);
attrs.a11yRoleLabel = app.translator.trans('core.forum.header.search_role_label', {}, true);
/**
* Get an array of virtual <li>s that list the search results for the given
* query.
*/
view(query: string): Array<Mithril.Vnode>;
}
export interface SearchAttrs extends ComponentAttrs {
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
state: SearchState;
}
/**
* @todo: 2.0 refactored the global search UI and no longer uses this component, now we use the GlobalSearch component.
* The component was kept to support extension usage of it on a local scope.
* We need to extract this component into a separate UI package instead as it is no longer needed by core.
*
* The `Search` component displays a menu of as-you-type results from a variety
* of sources.
*
* The search box will be 'activated' if the app's search state's
* getInitialSearch() value is a truthy value. If this is the case, an 'x'
* button will be shown next to the search field, and clicking it will clear the search.
*
* ATTRS:
*
* - state: SearchState instance.
*/
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
/**
* The minimum query length before sources are searched.
*/
protected static MIN_SEARCH_LEN = 3;
/**
* The instance of `SearchState` for this component.
*/
protected searchState!: SearchState;
/**
* Whether or not the search input has focus.
*/
protected hasFocus = false;
/**
* An array of SearchSources.
*/
protected sources?: SearchSource[];
/**
* The number of sources that are still loading results.
*/
protected loadingSources = 0;
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
* around as new results load), but otherwise it will be numeric (the
* sequential position within the list).
*/
protected index: number = 0;
protected navigator!: KeyboardNavigatable;
protected searchTimeout?: number;
private updateMaxHeightHandler?: () => void;
oninit(vnode: Mithril.Vnode<T, this>) {
super.oninit(vnode);
this.searchState = this.attrs.state;
}
view() {
const currentSearch = this.searchState.getInitialSearch();
// Initialize search sources in the view rather than the constructor so
// that we have access to app.forum.
if (!this.sources) this.sources = this.sourceItems().toArray();
// Hide the search view if no sources were loaded
if (!this.sources.length) return <div></div>;
const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder'));
const isActive = !!currentSearch;
const shouldShowResults = !!(this.searchState.getValue() && this.hasFocus);
const shouldShowClearButton = !!(!this.loadingSources && this.searchState.getValue());
return (
<div
role="search"
aria-label={app.translator.trans('core.forum.header.search_role_label')}
className={classList('Search', {
open: this.searchState.getValue() && this.hasFocus,
focused: this.hasFocus,
active: isActive,
loading: !!this.loadingSources,
})}
>
<div className="Search-input">
<input
aria-label={searchLabel}
className="FormControl"
type="search"
placeholder={searchLabel}
value={this.searchState.getValue()}
oninput={(e: InputEvent) => this.searchState.setValue((e?.target as HTMLInputElement)?.value)}
onfocus={() => (this.hasFocus = true)}
onblur={() => (this.hasFocus = false)}
/>
{!!this.loadingSources && <LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />}
{shouldShowClearButton && (
<button
className="Search-clear Button Button--icon Button--link"
onclick={this.clear.bind(this)}
aria-label={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
type="button"
>
<Icon name="fas fa-times-circle" />
</button>
)}
</div>
<ul
className="Dropdown-menu Search-results"
aria-hidden={!shouldShowResults || undefined}
aria-live={shouldShowResults ? 'polite' : undefined}
>
{shouldShowResults && this.sources.map((source) => source.view(this.searchState.getValue()))}
</ul>
</div>
);
}
updateMaxHeight() {
// Since extensions might add elements above the search box on mobile,
// we need to calculate and set the max height dynamically.
const resultsElementMargin = 14;
const maxHeight =
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
this.element.querySelector<HTMLElement>('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`);
}
onupdate(vnode: Mithril.VnodeDOM<T, this>) {
super.onupdate(vnode);
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
// If there are no sources, the search view is not shown.
if (!this.sources?.length) return;
this.updateMaxHeight();
}
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
super.oncreate(vnode);
// If there are no sources, we shouldn't initialize logic for
// search elements, as they will not be shown.
if (!this.sources?.length) return;
const search = this;
const state = this.searchState;
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
this.$('.Search-results')
.on('mousedown', (e) => e.preventDefault())
.on('click', () => this.$('input').trigger('blur'))
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header)', function () {
search.setIndex(search.selectableItems().index(this));
});
const $input = this.$('input') as JQuery<HTMLInputElement>;
this.navigator = new KeyboardNavigatable();
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectResult.bind(this), true)
.onCancel(this.clear.bind(this))
.bindTo($input);
// Handle input key events on the search input, triggering results to load.
$input
.on('input focus', function () {
const query = this.value.toLowerCase();
if (!query) return;
if (search.searchTimeout) clearTimeout(search.searchTimeout);
search.searchTimeout = window.setTimeout(() => {
if (state.isCached(query)) return;
if (query.length >= (search.constructor as typeof Search).MIN_SEARCH_LEN) {
search.sources?.map((source) => {
if (!source.search) return;
search.loadingSources++;
source.search(query).then(() => {
search.loadingSources = Math.max(0, search.loadingSources - 1);
m.redraw();
});
});
}
state.cache(query);
m.redraw();
}, 250);
})
.on('focus', function () {
$(this)
.one('mouseup', (e) => e.preventDefault())
.trigger('select');
});
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
window.addEventListener('resize', this.updateMaxHeightHandler);
}
onremove(vnode: Mithril.VnodeDOM<T, this>) {
super.onremove(vnode);
if (this.updateMaxHeightHandler) {
window.removeEventListener('resize', this.updateMaxHeightHandler);
}
}
/**
* Navigate to the currently selected search result and close the list.
*/
selectResult() {
if (this.searchTimeout) clearTimeout(this.searchTimeout);
this.loadingSources = 0;
const selectedUrl = this.getItem(this.index).find('a').attr('href');
if (this.searchState.getValue() && selectedUrl) {
m.route.set(selectedUrl);
} else {
this.clear();
}
this.$('input').blur();
}
/**
* Clear the search
*/
clear() {
this.searchState.clear();
}
/**
* Build an item list of SearchSources.
*/
sourceItems(): ItemList<SearchSource> {
const items = new ItemList<SearchSource>();
if (app.forum.attribute('canViewForum')) {
items.add('discussions', new DiscussionsSearchSource());
items.add('posts', new PostsSearchSource());
}
if (app.forum.attribute('canSearchUsers')) {
items.add('users', new UsersSearchSource());
}
if (app.forum.attribute('canViewForum')) items.add('discussions', new DiscussionsSearchSource());
if (app.forum.attribute('canSearchUsers')) items.add('users', new UsersSearchSource());
return items;
}
/**
* Get all of the search result items that are selectable.
*/
selectableItems(): JQuery {
return this.$('.Search-results > li:not(.Dropdown-header)');
}
/**
* Get the position of the currently selected search result item.
* Returns zero if not found.
*/
getCurrentNumericIndex(): number {
return Math.max(0, this.selectableItems().index(this.getItem(this.index)));
}
/**
* Get the <li> in the search results with the given index (numeric or named).
*/
getItem(index: number): JQuery {
const $items = this.selectableItems();
let $item = $items.filter(`[data-index="${index}"]`);
if (!$item.length) {
$item = $items.eq(index);
}
return $item;
}
/**
* Set the currently-selected search result item to the one with the given
* index.
*/
setIndex(index: number, scrollToItem: boolean = false) {
const $items = this.selectableItems();
const $dropdown = $items.parent();
let fixedIndex = index;
if (index < 0) {
fixedIndex = $items.length - 1;
} else if (index >= $items.length) {
fixedIndex = 0;
}
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
if (scrollToItem) {
const dropdownScroll = $dropdown.scrollTop()!;
const dropdownTop = $dropdown.offset()!.top;
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
const itemTop = $item.offset()!.top;
const itemBottom = itemTop + $item.outerHeight()!;
let scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
}
if (typeof scrollTop !== 'undefined') {
$dropdown.stop(true).animate({ scrollTop }, 100);
}
}
}
}

View File

@ -1,33 +1,25 @@
import type Mithril from 'mithril';
import app from '../app';
import type User from '../../common/models/User';
import type { SearchSource } from './Search';
import extractText from '../../common/utils/extractText';
import UserSearchResult from '../../common/components/UserSearchResult';
import app from '../../forum/app';
import highlight from '../../common/helpers/highlight';
import Avatar from '../../common/components/Avatar';
import username from '../../common/helpers/username';
import Link from '../../common/components/Link';
import { SearchSource } from './Search';
import User from '../../common/models/User';
/**
* The `UsersSearchSource` finds and displays user search results in the search
* dropdown.
*/
export default class UsersSearchSource implements SearchSource {
export default class UsersSearchResults implements SearchSource {
protected results = new Map<string, User[]>();
public resource: string = 'users';
title(): string {
return extractText(app.translator.trans('core.lib.search_source.users.heading'));
}
isCached(query: string): boolean {
return this.results.has(query.toLowerCase());
}
async search(query: string, limit: number): Promise<void> {
async search(query: string): Promise<void> {
return app.store
.find<User[]>('users', {
filter: { q: query },
page: { limit },
page: { limit: 5 },
})
.then((results) => {
this.results.set(query, results);
@ -49,22 +41,20 @@ export default class UsersSearchSource implements SearchSource {
if (!results.length) return [];
return results.map((user) => <UserSearchResult user={user} query={query} />);
}
return [
<li className="Dropdown-header">{app.translator.trans('core.lib.search_source.users.heading')}</li>,
...results.map((user) => {
const name = username(user, (name: string) => highlight(name, query));
customGrouping(): boolean {
return false;
}
fullPage(query: string): null {
return null;
}
gotoItem(id: string): string | null {
const user = app.store.getById<User>('users', id);
if (!user) return null;
return app.route.user(user);
return (
<li className="UserSearchResult" data-index={'users' + user.id()}>
<Link href={app.route.user(user)}>
<Avatar user={user} />
{name}
</Link>
</li>
);
}),
];
}
}

View File

@ -22,7 +22,7 @@ import './components/HeaderPrimary';
import './components/PostEdited';
import './components/IndexPage';
import './components/DiscussionRenamedNotification';
import './components/DiscussionsSearchSource';
import './components/GlobalDiscussionsSearchSource';
import './components/HeaderSecondary';
import './components/DiscussionList';
import './components/AvatarEditor';
@ -33,7 +33,7 @@ import './components/NotificationsDropdown';
import './components/UserPage';
import './components/PostUser';
import './components/UserCard';
import './components/UsersSearchSource';
import './components/GlobalUsersSearchSource';
import './components/PostPreview';
import './components/EventPost';
import './components/DiscussionHero';
@ -45,6 +45,7 @@ import './components/WelcomeHero';
import './components/CommentPost';
import './components/ComposerPostPreview';
import './components/RenameDiscussionModal';
import './components/GlobalSearch';
import './components/Search';
import './components/DiscussionListItem';
import './components/PostsUserPage';

View File

@ -2,7 +2,7 @@ import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import mq from 'mithril-query';
import { app } from '../../../../src/forum';
import ModalManager from '../../../../src/common/components/ModalManager';
import DiscussionsSearchSource from '../../../../src/forum/components/DiscussionsSearchSource';
import GlobalDiscussionsSearchSource from '../../../../src/forum/components/GlobalDiscussionsSearchSource';
import ChangeEmailModal from '../../../../src/forum/components/ChangeEmailModal';
import ChangePasswordModal from '../../../../src/forum/components/ChangePasswordModal';
import ForgotPasswordModal from '../../../../src/forum/components/ForgotPasswordModal';
@ -87,7 +87,7 @@ describe('Modals', () => {
test('SearchModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(SearchModal, { searchState: app.search.state, sources: [new DiscussionsSearchSource()] });
app.modal.show(SearchModal, { searchState: app.search.state, sources: [new GlobalDiscussionsSearchSource()] });
manager.redraw();