diff --git a/framework/core/js/src/admin/components/GeneralSearchSource.tsx b/framework/core/js/src/admin/components/GeneralSearchSource.tsx index 2864dc5afc..3b416cc190 100644 --- a/framework/core/js/src/admin/components/GeneralSearchSource.tsx +++ b/framework/core/js/src/admin/components/GeneralSearchSource.tsx @@ -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(); public resource: string = 'general'; diff --git a/framework/core/js/src/admin/components/Search.tsx b/framework/core/js/src/admin/components/GlobalSearch.tsx similarity index 53% rename from framework/core/js/src/admin/components/Search.tsx rename to framework/core/js/src/admin/components/GlobalSearch.tsx index 0fee6b024b..547ba9c348 100644 --- a/framework/core/js/src/admin/components/Search.tsx +++ b/framework/core/js/src/admin/components/GlobalSearch.tsx @@ -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 { - const items = new ItemList(); + sourceItems(): ItemList { + const items = new ItemList(); items.add('general', new GeneralSearchSource()); diff --git a/framework/core/js/src/admin/components/HeaderSecondary.tsx b/framework/core/js/src/admin/components/HeaderSecondary.tsx index e89fe3ab37..edabca2f89 100644 --- a/framework/core/js/src/admin/components/HeaderSecondary.tsx +++ b/framework/core/js/src/admin/components/HeaderSecondary.tsx @@ -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(); - items.add('search', , 30); + items.add('search', , 30); items.add( 'help', diff --git a/framework/core/js/src/admin/components/StatusWidget.js b/framework/core/js/src/admin/components/StatusWidget.js index 15176d2390..18a2d38cad 100644 --- a/framework/core/js/src/admin/components/StatusWidget.js +++ b/framework/core/js/src/admin/components/StatusWidget.js @@ -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() { diff --git a/framework/core/js/src/common/components/AbstractSearch.tsx b/framework/core/js/src/common/components/AbstractGlobalSearch.tsx similarity index 89% rename from framework/core/js/src/common/components/AbstractSearch.tsx rename to framework/core/js/src/common/components/AbstractGlobalSearch.tsx index c7c40e9bc6..ccc8614f8a 100644 --- a/framework/core/js/src/common/components/AbstractSearch.tsx +++ b/framework/core/js/src/common/components/AbstractGlobalSearch.tsx @@ -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 extends Component { +export default abstract class AbstractGlobalSearch extends Component { /** * The instance of `SearchState` for this component. */ @@ -136,5 +136,5 @@ export default abstract class AbstractSearch; + abstract sourceItems(): ItemList; } diff --git a/framework/core/js/src/common/components/SearchModal.tsx b/framework/core/js/src/common/components/SearchModal.tsx index 659ce2f614..29e8dd248e 100644 --- a/framework/core/js/src/common/components/SearchModal.tsx +++ b/framework/core/js/src/common/components/SearchModal.tsx @@ -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 extends FormModal { @@ -32,12 +32,12 @@ export default class SearchModal; + protected activeSource!: Stream; /** * The sources that are still loading results. @@ -214,7 +214,7 @@ export default class SearchModal { + query!: string; + discussion!: Discussion; + mostRelevantPost!: Post | null | undefined; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.query = this.attrs.query; + this.discussion = this.attrs.discussion; + this.mostRelevantPost = this.attrs.mostRelevantPost; + } + + view() { + return ( +
  • + + {this.viewItems().toArray()} + +
  • + ); + } + + discussionTitle() { + return this.discussion.title(); + } + + mostRelevantPostContent() { + return this.mostRelevantPost?.contentPlain(); + } + + viewItems(): ItemList { + const items = new ItemList(); + + items.add('discussion-title',
    {highlight(this.discussionTitle(), this.query)}
    , 90); + + !!this.mostRelevantPost && + items.add( + 'most-relevant', +
    {highlight(this.mostRelevantPostContent() ?? '', this.query, 100)}
    , + 80 + ); + + return items; + } +} diff --git a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx index 957bcc3607..1f4fb828b9 100644 --- a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx @@ -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(); + 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 { + async search(query: string): Promise { 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('discussions', params).then((results) => { @@ -43,38 +35,38 @@ export default class DiscussionsSearchSource implements SearchSource { view(query: string): Array { query = query.toLowerCase(); - return (this.results.get(query) || []).map((discussion) => { - return ( -
  • - -
  • - ); - }) as Array; - } + this.setQueryString(query); - customGrouping(): boolean { - return false; - } + const results = (this.results.get(query) || []).map((discussion) => { + const mostRelevantPost = discussion.mostRelevantPost(); - fullPage(query: string): Mithril.Vnode { - const filter = app.search.gambits.apply('discussions', { q: query }); - const q = filter.q || null; - delete filter.q; + return ; + }) as Array; - return ( + return [ +
  • {app.translator.trans('core.lib.search_source.discussions.heading')}
  • ,
  • - + {app.translator.trans('core.lib.search_source.discussions.all_button', { query })} -
  • - ); + , + ...results, + ]; } - gotoItem(id: string): string | null { - const discussion = app.store.getById('discussions', id); + includes(): string[] { + return ['mostRelevantPost']; + } - if (!discussion) return null; + limit(): number { + return 3; + } + + queryMutators(): string[] { + return []; + } - return app.route.discussion(discussion); + setQueryString(query: string): void { + this.queryString = query + ' ' + this.queryMutators().join(' '); } } diff --git a/framework/core/js/src/forum/components/GlobalDiscussionsSearchSource.tsx b/framework/core/js/src/forum/components/GlobalDiscussionsSearchSource.tsx new file mode 100644 index 0000000000..2c5f2ba14c --- /dev/null +++ b/framework/core/js/src/forum/components/GlobalDiscussionsSearchSource.tsx @@ -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(); + + 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 { + query = query.toLowerCase(); + + this.results.set(query, []); + + const params = { + filter: { q: query }, + page: { limit }, + include: 'mostRelevantPost,user,firstPost,tags', + }; + + return app.store.find('discussions', params).then((results) => { + this.results.set(query, results); + m.redraw(); + }); + } + + view(query: string): Array { + query = query.toLowerCase(); + + return (this.results.get(query) || []).map((discussion) => { + return ( +
  • + +
  • + ); + }) as Array; + } + + 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 ( +
  • + + {app.translator.trans('core.lib.search_source.discussions.all_button', { query })} + +
  • + ); + } + + gotoItem(id: string): string | null { + const discussion = app.store.getById('discussions', id); + + if (!discussion) return null; + + return app.route.discussion(discussion); + } +} diff --git a/framework/core/js/src/forum/components/PostsSearchSource.tsx b/framework/core/js/src/forum/components/GlobalPostsSearchSource.tsx similarity index 94% rename from framework/core/js/src/forum/components/PostsSearchSource.tsx rename to framework/core/js/src/forum/components/GlobalPostsSearchSource.tsx index 8dc9936e8c..b596961add 100644 --- a/framework/core/js/src/forum/components/PostsSearchSource.tsx +++ b/framework/core/js/src/forum/components/GlobalPostsSearchSource.tsx @@ -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(); public resource: string = 'posts'; diff --git a/framework/core/js/src/forum/components/GlobalSearch.tsx b/framework/core/js/src/forum/components/GlobalSearch.tsx new file mode 100644 index 0000000000..1bbb85a1dc --- /dev/null +++ b/framework/core/js/src/forum/components/GlobalSearch.tsx @@ -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 extends AbstractGlobalSearch { + 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 { + const items = new ItemList(); + + 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; + } +} diff --git a/framework/core/js/src/forum/components/GlobalUsersSearchSource.tsx b/framework/core/js/src/forum/components/GlobalUsersSearchSource.tsx new file mode 100644 index 0000000000..5dd3b0f06c --- /dev/null +++ b/framework/core/js/src/forum/components/GlobalUsersSearchSource.tsx @@ -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(); + + 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 { + return app.store + .find('users', { + filter: { q: query }, + page: { limit }, + }) + .then((results) => { + this.results.set(query, results); + m.redraw(); + }); + } + + view(query: string): Array { + query = query.toLowerCase(); + + const results = (this.results.get(query) || []) + .concat( + app.store + .all('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) => ); + } + + customGrouping(): boolean { + return false; + } + + fullPage(query: string): null { + return null; + } + + gotoItem(id: string): string | null { + const user = app.store.getById('users', id); + + if (!user) return null; + + return app.route.user(user); + } +} diff --git a/framework/core/js/src/forum/components/HeaderSecondary.js b/framework/core/js/src/forum/components/HeaderSecondary.js index 56a929439f..7386daf07c 100644 --- a/framework/core/js/src/forum/components/HeaderSecondary.js +++ b/framework/core/js/src/forum/components/HeaderSecondary.js @@ -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', , 30); + items.add('search', , 30); if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) { const locales = []; diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index 44d4a7f795..6d0c45c92b 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -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; -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
  • s that list the search results for the given + * query. + */ + view(query: string): Array; +} + +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 extends Component { + /** + * 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
  • 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) { + 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
    ; + + 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 ( +
    +
    + this.searchState.setValue((e?.target as HTMLInputElement)?.value)} + onfocus={() => (this.hasFocus = true)} + onblur={() => (this.hasFocus = false)} + /> + {!!this.loadingSources && } + {shouldShowClearButton && ( + + )} +
    +
      + {shouldShowResults && this.sources.map((source) => source.view(this.searchState.getValue()))} +
    +
    + ); + } + + 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('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`); + } + + onupdate(vnode: Mithril.VnodeDOM) { + 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) { + 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; + + 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) { + 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 { const items = new ItemList(); - if (app.forum.attribute('canViewForum')) { - items.add('discussions', new DiscussionsSearchSource()); - items.add('posts', new PostsSearchSource()); + 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
  • 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); } - if (app.forum.attribute('canSearchUsers')) { - items.add('users', new UsersSearchSource()); + 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; } - return items; + 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); + } + } } } diff --git a/framework/core/js/src/forum/components/UsersSearchSource.tsx b/framework/core/js/src/forum/components/UsersSearchSource.tsx index 7b3212d4f9..d07ee62634 100644 --- a/framework/core/js/src/forum/components/UsersSearchSource.tsx +++ b/framework/core/js/src/forum/components/UsersSearchSource.tsx @@ -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(); - 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 { + async search(query: string): Promise { return app.store .find('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) => ); - } - - customGrouping(): boolean { - return false; - } - - fullPage(query: string): null { - return null; - } - - gotoItem(id: string): string | null { - const user = app.store.getById('users', id); - - if (!user) return null; - - return app.route.user(user); + return [ +
  • {app.translator.trans('core.lib.search_source.users.heading')}
  • , + ...results.map((user) => { + const name = username(user, (name: string) => highlight(name, query)); + + return ( +
  • + + + {name} + +
  • + ); + }), + ]; } } diff --git a/framework/core/js/src/forum/forum.ts b/framework/core/js/src/forum/forum.ts index 1fe1a43815..11a9ccdb00 100644 --- a/framework/core/js/src/forum/forum.ts +++ b/framework/core/js/src/forum/forum.ts @@ -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'; diff --git a/framework/core/js/tests/integration/forum/components/Modals.test.ts b/framework/core/js/tests/integration/forum/components/Modals.test.ts index 58d596e458..e10cacebbb 100644 --- a/framework/core/js/tests/integration/forum/components/Modals.test.ts +++ b/framework/core/js/tests/integration/forum/components/Modals.test.ts @@ -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();