mirror of
https://github.com/flarum/framework.git
synced 2025-02-21 14:24:57 +08:00
chore: recover local search component (#4104)
This commit is contained in:
parent
04fe684db8
commit
8c331038da
@ -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';
|
||||
|
@ -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());
|
||||
|
@ -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',
|
||||
|
@ -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() {
|
||||
|
@ -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>;
|
||||
}
|
@ -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());
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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(' ');
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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';
|
35
framework/core/js/src/forum/components/GlobalSearch.tsx
Normal file
35
framework/core/js/src/forum/components/GlobalSearch.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 = [];
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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();
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user