1
0
mirror of https://github.com/flarum/core.git synced 2025-08-08 17:36:38 +02:00

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
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 app from '../app';
import highlight from '../../common/helpers/highlight'; import highlight from '../../common/helpers/highlight';
import type { SearchSource } from './Search'; import type { GlobalSearchSource } from './GlobalSearch';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import Link from '../../common/components/Link'; import Link from '../../common/components/Link';
import Icon from '../../common/components/Icon'; 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. * 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[]>(); protected results = new Map<string, GeneralSearchResult[]>();
public resource: string = 'general'; public resource: string = 'general';

View File

@@ -1,18 +1,21 @@
import ItemList from '../../common/utils/ItemList'; 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 GeneralSearchSource from './GeneralSearchSource';
import app from '../app'; 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) { static initAttrs(attrs: SearchAttrs) {
attrs.label = app.translator.trans('core.admin.header.search_placeholder', {}, true); attrs.label = app.translator.trans('core.admin.header.search_placeholder', {}, true);
attrs.a11yRoleLabel = app.translator.trans('core.admin.header.search_role_label', {}, true); attrs.a11yRoleLabel = app.translator.trans('core.admin.header.search_role_label', {}, true);
} }
sourceItems(): ItemList<SearchSource> { sourceItems(): ItemList<GlobalSearchSource> {
const items = new ItemList<SearchSource>(); const items = new ItemList<GlobalSearchSource>();
items.add('general', new GeneralSearchSource()); items.add('general', new GeneralSearchSource());

View File

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

View File

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

View File

@@ -14,16 +14,16 @@ export interface SearchAttrs extends ComponentAttrs {
} }
/** /**
* The `SearchSource` interface defines a section of search results in the * The `SearchSource` interface defines a tab of search results in the
* search dropdown. * 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 * by extending the `sourceItems` method. When the user types a
* query, each search source will be prompted to load search results via the * 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. * 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. * 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. * 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. * 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. * 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 type IGambit from '../query/IGambit';
import ItemList from '../utils/ItemList'; import ItemList from '../utils/ItemList';
import GambitsAutocomplete from '../utils/GambitsAutocomplete'; import GambitsAutocomplete from '../utils/GambitsAutocomplete';
import type { SearchSource } from './AbstractSearch'; import type { GlobalSearchSource } from './AbstractGlobalSearch';
export interface ISearchModalAttrs extends IFormModalAttrs { export interface ISearchModalAttrs extends IFormModalAttrs {
onchange: (value: string) => void; onchange: (value: string) => void;
searchState: SearchState; searchState: SearchState;
sources: SearchSource[]; sources: GlobalSearchSource[];
} }
export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearchModalAttrs> extends FormModal<CustomAttrs> { 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. * An array of SearchSources.
*/ */
protected sources!: SearchSource[]; protected sources!: GlobalSearchSource[];
/** /**
* The key of the currently-active search source. * The key of the currently-active search source.
*/ */
protected activeSource!: Stream<SearchSource>; protected activeSource!: Stream<GlobalSearchSource>;
/** /**
* The sources that are still loading results. * The sources that are still loading results.
@@ -214,7 +214,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
return items; return items;
} }
switchSource(source: SearchSource) { switchSource(source: GlobalSearchSource) {
if (this.activeSource() !== source) { if (this.activeSource() !== source) {
this.activeSource(source); this.activeSource(source);
this.search(this.query()); 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 LinkButton from '../../common/components/LinkButton';
import { SearchSource } from './Search';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import type Discussion from '../../common/models/Discussion'; import Discussion from '../../common/models/Discussion';
import type { SearchSource } from './Search'; import DiscussionsSearchItem from './DiscussionsSearchItem';
import extractText from '../../common/utils/extractText';
import MinimalDiscussionListItem from './MinimalDiscussionListItem';
/** /**
* The `DiscussionsSearchSource` finds and displays discussion search results in * The `DiscussionsSearchSource` finds and displays discussion search results in
@@ -12,26 +11,19 @@ import MinimalDiscussionListItem from './MinimalDiscussionListItem';
*/ */
export default class DiscussionsSearchSource implements SearchSource { export default class DiscussionsSearchSource implements SearchSource {
protected results = new Map<string, Discussion[]>(); protected results = new Map<string, Discussion[]>();
queryString: string | null = null;
public resource: string = 'discussions'; async search(query: string): Promise<void> {
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(); query = query.toLowerCase();
this.results.set(query, []); this.results.set(query, []);
this.setQueryString(query);
const params = { const params = {
filter: { q: query }, filter: { q: this.queryString || query },
page: { limit }, page: { limit: this.limit() },
include: 'mostRelevantPost,user,firstPost,tags', include: this.includes().join(','),
}; };
return app.store.find<Discussion[]>('discussions', params).then((results) => { 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> { view(query: string): Array<Mithril.Vnode> {
query = query.toLowerCase(); query = query.toLowerCase();
return (this.results.get(query) || []).map((discussion) => { this.setQueryString(query);
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()} data-id={discussion.id()}> const results = (this.results.get(query) || []).map((discussion) => {
<MinimalDiscussionListItem discussion={discussion} params={{ q: query }} /> const mostRelevantPost = discussion.mostRelevantPost();
</li>
); return <DiscussionsSearchItem query={query} discussion={discussion} mostRelevantPost={mostRelevantPost} />;
}) as Array<Mithril.Vnode>; }) as Array<Mithril.Vnode>;
}
customGrouping(): boolean { return [
return false; <li className="Dropdown-header">{app.translator.trans('core.lib.search_source.discussions.heading')}</li>,
}
fullPage(query: string): Mithril.Vnode {
const filter = app.search.gambits.apply('discussions', { q: query });
const q = filter.q || null;
delete filter.q;
return (
<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 })} {app.translator.trans('core.lib.search_source.discussions.all_button', { query })}
</LinkButton> </LinkButton>
</li> </li>,
); ...results,
];
} }
gotoItem(id: string): string | null { includes(): string[] {
const discussion = app.store.getById<Discussion>('discussions', id); 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 LinkButton from '../../common/components/LinkButton';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import type Post from '../../common/models/Post'; import type Post from '../../common/models/Post';
import type { SearchSource } from './Search'; import type { GlobalSearchSource } from './GlobalSearch';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import MinimalDiscussionListItem from './MinimalDiscussionListItem'; import MinimalDiscussionListItem from './MinimalDiscussionListItem';
@@ -10,7 +10,7 @@ import MinimalDiscussionListItem from './MinimalDiscussionListItem';
* The `PostsSearchSource` finds and displays post search results in * The `PostsSearchSource` finds and displays post search results in
* the search dropdown. * the search dropdown.
*/ */
export default class PostsSearchSource implements SearchSource { export default class GlobalPostsSearchSource implements GlobalSearchSource {
protected results = new Map<string, Post[]>(); protected results = new Map<string, Post[]>();
public resource: string = 'posts'; 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 NotificationsDropdown from './NotificationsDropdown';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import Search from '../components/Search'; import GlobalSearch from './GlobalSearch';
/** /**
* The `HeaderSecondary` component displays secondary header controls, such as * The `HeaderSecondary` component displays secondary header controls, such as
@@ -26,7 +26,7 @@ export default class HeaderSecondary extends Component {
items() { items() {
const items = new ItemList(); 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) { if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
const locales = []; const locales = [];

View File

@@ -1,30 +1,371 @@
import app from '../../forum/app'; import app from '../../forum/app';
import Component, { ComponentAttrs } from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ItemList from '../../common/utils/ItemList'; 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 DiscussionsSearchSource from './DiscussionsSearchSource';
import UsersSearchSource from './UsersSearchSource'; import UsersSearchSource from './UsersSearchSource';
import PostsSearchSource from './PostsSearchSource'; import type Mithril from 'mithril';
import AbstractSearch, { type SearchAttrs, type SearchSource as BaseSearchSource } from '../../common/components/AbstractSearch';
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) { * Get an array of virtual <li>s that list the search results for the given
attrs.label = app.translator.trans('core.forum.header.search_placeholder', {}, true); * query.
attrs.a11yRoleLabel = app.translator.trans('core.forum.header.search_role_label', {}, true); */
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> { sourceItems(): ItemList<SearchSource> {
const items = new ItemList<SearchSource>(); const items = new ItemList<SearchSource>();
if (app.forum.attribute('canViewForum')) { if (app.forum.attribute('canViewForum')) items.add('discussions', new DiscussionsSearchSource());
items.add('discussions', new DiscussionsSearchSource()); if (app.forum.attribute('canSearchUsers')) items.add('users', new UsersSearchSource());
items.add('posts', new PostsSearchSource());
}
if (app.forum.attribute('canSearchUsers')) {
items.add('users', new UsersSearchSource());
}
return items; 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 type Mithril from 'mithril';
import app from '../app'; import app from '../../forum/app';
import type User from '../../common/models/User'; import highlight from '../../common/helpers/highlight';
import type { SearchSource } from './Search'; import Avatar from '../../common/components/Avatar';
import extractText from '../../common/utils/extractText'; import username from '../../common/helpers/username';
import UserSearchResult from '../../common/components/UserSearchResult'; 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 * The `UsersSearchSource` finds and displays user search results in the search
* dropdown. * dropdown.
*/ */
export default class UsersSearchSource implements SearchSource { export default class UsersSearchResults implements SearchSource {
protected results = new Map<string, User[]>(); protected results = new Map<string, User[]>();
public resource: string = 'users'; async search(query: string): Promise<void> {
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 return app.store
.find<User[]>('users', { .find<User[]>('users', {
filter: { q: query }, filter: { q: query },
page: { limit }, page: { limit: 5 },
}) })
.then((results) => { .then((results) => {
this.results.set(query, results); this.results.set(query, results);
@@ -49,22 +41,20 @@ export default class UsersSearchSource implements SearchSource {
if (!results.length) return []; 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 (
return false; <li className="UserSearchResult" data-index={'users' + user.id()}>
} <Link href={app.route.user(user)}>
<Avatar user={user} />
fullPage(query: string): null { {name}
return null; </Link>
} </li>
);
gotoItem(id: string): string | null { }),
const user = app.store.getById<User>('users', id); ];
if (!user) return null;
return app.route.user(user);
} }
} }

View File

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

View File

@@ -2,7 +2,7 @@ import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import mq from 'mithril-query'; import mq from 'mithril-query';
import { app } from '../../../../src/forum'; import { app } from '../../../../src/forum';
import ModalManager from '../../../../src/common/components/ModalManager'; 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 ChangeEmailModal from '../../../../src/forum/components/ChangeEmailModal';
import ChangePasswordModal from '../../../../src/forum/components/ChangePasswordModal'; import ChangePasswordModal from '../../../../src/forum/components/ChangePasswordModal';
import ForgotPasswordModal from '../../../../src/forum/components/ForgotPasswordModal'; import ForgotPasswordModal from '../../../../src/forum/components/ForgotPasswordModal';
@@ -87,7 +87,7 @@ describe('Modals', () => {
test('SearchModal renders', () => { test('SearchModal renders', () => {
const manager = mq(ModalManager, { state: app.modal }); 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(); manager.redraw();