Remove app.search instance, cache app.cache.searched (#2151)

* Moved search state logic into search state
This commit is contained in:
Alexander Skvortsov 2020-06-18 18:47:01 -04:00 committed by GitHub
parent df15d9ae3f
commit 4013aed078
7 changed files with 169 additions and 131 deletions

View File

@ -1,6 +1,5 @@
import History from './utils/History'; import History from './utils/History';
import Pane from './utils/Pane'; import Pane from './utils/Pane';
import Search from './components/Search';
import ReplyComposer from './components/ReplyComposer'; import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage'; import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal'; import SignUpModal from './components/SignUpModal';
@ -15,6 +14,7 @@ import alertEmailConfirmation from './utils/alertEmailConfirmation';
import Application from '../common/Application'; import Application from '../common/Application';
import Navigation from '../common/components/Navigation'; import Navigation from '../common/components/Navigation';
import NotificationListState from './states/NotificationListState'; import NotificationListState from './states/NotificationListState';
import GlobalSearchState from './states/GlobalSearchState';
export default class ForumApplication extends Application { export default class ForumApplication extends Application {
/** /**
@ -35,13 +35,6 @@ export default class ForumApplication extends Application {
discussionRenamed: DiscussionRenamedPost, discussionRenamed: DiscussionRenamedPost,
}; };
/**
* The page's search component instance.
*
* @type {Search}
*/
search = new Search();
/** /**
* An object which controls the state of the page's side pane. * An object which controls the state of the page's side pane.
* *
@ -71,6 +64,14 @@ export default class ForumApplication extends Application {
*/ */
notifications = new NotificationListState(this); notifications = new NotificationListState(this);
/*
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.
*
* @type {GlobalSearchState}
*/
search = new GlobalSearchState();
constructor() { constructor() {
super(); super();

View File

@ -7,6 +7,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';
/** /**
* The `HeaderSecondary` component displays secondary header controls, such as * The `HeaderSecondary` component displays secondary header controls, such as
@ -33,7 +34,7 @@ export default class HeaderSecondary extends Component {
items() { items() {
const items = new ItemList(); const items = new ItemList();
items.add('search', app.search.render(), 30); items.add('search', Search.component({ state: app.search }), 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

@ -2,7 +2,6 @@ import { extend } from '../../common/extend';
import Page from './Page'; import Page from './Page';
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 icon from '../../common/helpers/icon';
import DiscussionList from './DiscussionList'; import DiscussionList from './DiscussionList';
import WelcomeHero from './WelcomeHero'; import WelcomeHero from './WelcomeHero';
import DiscussionComposer from './DiscussionComposer'; import DiscussionComposer from './DiscussionComposer';
@ -18,6 +17,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
* hero, the sidebar, and the discussion list. * hero, the sidebar, and the discussion list.
*/ */
export default class IndexPage extends Page { export default class IndexPage extends Page {
static providesInitialSearch = true;
init() { init() {
super.init(); super.init();
@ -36,7 +37,7 @@ export default class IndexPage extends Page {
app.cache.discussionList = null; app.cache.discussionList = null;
} }
const params = this.params(); const params = app.search.params();
if (app.cache.discussionList) { if (app.cache.discussionList) {
// Compare the requested parameters (sort, search query) to the ones that // Compare the requested parameters (sort, search query) to the ones that
@ -187,7 +188,7 @@ export default class IndexPage extends Page {
*/ */
navItems() { navItems() {
const items = new ItemList(); const items = new ItemList();
const params = this.stickyParams(); const params = app.search.stickyParams();
items.add( items.add(
'allDiscussions', 'allDiscussions',
@ -222,15 +223,15 @@ export default class IndexPage extends Page {
'sort', 'sort',
Dropdown.component({ Dropdown.component({
buttonClassName: 'Button', buttonClassName: 'Button',
label: sortOptions[this.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0], label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
children: Object.keys(sortOptions).map((value) => { children: Object.keys(sortOptions).map((value) => {
const label = sortOptions[value]; const label = sortOptions[value];
const active = (this.params().sort || Object.keys(sortMap)[0]) === value; const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component({ return Button.component({
children: label, children: label,
icon: active ? 'fas fa-check' : true, icon: active ? 'fas fa-check' : true,
onclick: this.changeSort.bind(this, value), onclick: app.search.changeSort.bind(app.search, value),
active: active, active: active,
}); });
}), }),
@ -280,72 +281,6 @@ export default class IndexPage extends Page {
return items; return items;
} }
/**
* Return the current search query, if any. This is implemented to activate
* the search box in the header.
*
* @see Search
* @return {String}
*/
searching() {
return this.params().q;
}
/**
* Redirect to the index page without a search filter. This is called when the
* 'x' is clicked in the search box in the header.
*
* @see Search
*/
clearSearch() {
const params = this.params();
delete params.q;
m.route(app.route(this.props.routeName, params));
}
/**
* Redirect to the index page using the given sort parameter.
*
* @param {String} sort
*/
changeSort(sort) {
const params = this.params();
if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) {
delete params.sort;
} else {
params.sort = sort;
}
m.route(app.route(this.props.routeName, params));
}
/**
* Get URL parameters that stick between filter changes.
*
* @return {Object}
*/
stickyParams() {
return {
sort: m.route.param('sort'),
q: m.route.param('q'),
};
}
/**
* Get parameters to pass to the DiscussionList component.
*
* @return {Object}
*/
params() {
const params = this.stickyParams();
params.filter = m.route.param('filter');
return params;
}
/** /**
* Open the composer for a new discussion or prompt the user to login. * Open the composer for a new discussion or prompt the user to login.
* *

View File

@ -12,19 +12,17 @@ import UsersSearchSource from './UsersSearchSource';
* The `Search` component displays a menu of as-you-type results from a variety * The `Search` component displays a menu of as-you-type results from a variety
* of sources. * of sources.
* *
* The search box will be 'activated' if the app's current controller implements * The search box will be 'activated' if the app's seach state's
* a `searching` method that returns a truthy value. If this is the case, an 'x' * 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 call the * button will be shown next to the search field, and clicking it will clear the search.
* `clearSearch` method on the controller. *
* PROPS:
*
* - state: AlertState instance.
*/ */
export default class Search extends Component { export default class Search extends Component {
init() { init() {
/** this.state = this.props.state;
* The value of the search input.
*
* @type {Function}
*/
this.value = m.prop('');
/** /**
* Whether or not the search input has focus. * Whether or not the search input has focus.
@ -47,13 +45,6 @@ export default class Search extends Component {
*/ */
this.loadingSources = 0; this.loadingSources = 0;
/**
* A list of queries that have been searched for.
*
* @type {Array}
*/
this.searched = [];
/** /**
* The index of the currently-selected <li> in the results list. This can be * 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 * a unique string (to account for the fact that an item's position may jump
@ -66,13 +57,7 @@ export default class Search extends Component {
} }
view() { view() {
const currentSearch = this.getCurrentSearch(); const currentSearch = this.state.getInitialSearch();
// Initialize search input value in the view rather than the constructor so
// that we have access to app.current.
if (typeof this.value() === 'undefined') {
this.value(currentSearch || '');
}
// Initialize search sources in the view rather than the constructor so // Initialize search sources in the view rather than the constructor so
// that we have access to app.forum. // that we have access to app.forum.
@ -88,7 +73,7 @@ export default class Search extends Component {
className={ className={
'Search ' + 'Search ' +
classList({ classList({
open: this.value() && this.hasFocus, open: this.state.getValue() && this.hasFocus,
focused: this.hasFocus, focused: this.hasFocus,
active: !!currentSearch, active: !!currentSearch,
loading: !!this.loadingSources, loading: !!this.loadingSources,
@ -100,8 +85,8 @@ export default class Search extends Component {
className="FormControl" className="FormControl"
type="search" type="search"
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))} placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
value={this.value()} value={this.state.getValue()}
oninput={m.withAttr('value', this.value)} oninput={m.withAttr('value', this.state.setValue.bind(this.state))}
onfocus={() => (this.hasFocus = true)} onfocus={() => (this.hasFocus = true)}
onblur={() => (this.hasFocus = false)} onblur={() => (this.hasFocus = false)}
/> />
@ -116,7 +101,7 @@ export default class Search extends Component {
)} )}
</div> </div>
<ul className="Dropdown-menu Search-results"> <ul className="Dropdown-menu Search-results">
{this.value() && this.hasFocus ? this.sources.map((source) => source.view(this.value())) : ''} {this.state.getValue() && this.hasFocus ? this.sources.map((source) => source.view(this.state.getValue())) : ''}
</ul> </ul>
</div> </div>
); );
@ -129,6 +114,7 @@ export default class Search extends Component {
if (isInitialized) return; if (isInitialized) return;
const search = this; const search = this;
const state = this.state;
this.$('.Search-results') this.$('.Search-results')
.on('mousedown', (e) => e.preventDefault()) .on('mousedown', (e) => e.preventDefault())
@ -158,7 +144,7 @@ export default class Search extends Component {
clearTimeout(search.searchTimeout); clearTimeout(search.searchTimeout);
search.searchTimeout = setTimeout(() => { search.searchTimeout = setTimeout(() => {
if (search.searched.indexOf(query) !== -1) return; if (state.isCached(query)) return;
if (query.length >= 3) { if (query.length >= 3) {
search.sources.map((source) => { search.sources.map((source) => {
@ -173,7 +159,7 @@ export default class Search extends Component {
}); });
} }
search.searched.push(query); state.cache(query);
m.redraw(); m.redraw();
}, 250); }, 250);
}) })
@ -185,15 +171,6 @@ export default class Search extends Component {
}); });
} }
/**
* Get the active search in the app's current controller.
*
* @return {String}
*/
getCurrentSearch() {
return app.current && typeof app.current.searching === 'function' && app.current.searching();
}
/** /**
* Navigate to the currently selected search result and close the list. * Navigate to the currently selected search result and close the list.
*/ */
@ -201,7 +178,7 @@ export default class Search extends Component {
clearTimeout(this.searchTimeout); clearTimeout(this.searchTimeout);
this.loadingSources = 0; this.loadingSources = 0;
if (this.value()) { if (this.state.getValue()) {
m.route(this.getItem(this.index).find('a').attr('href')); m.route(this.getItem(this.index).find('a').attr('href'));
} else { } else {
this.clear(); this.clear();
@ -211,16 +188,10 @@ export default class Search extends Component {
} }
/** /**
* Clear the search input and the current controller's active search. * Clear the search
*/ */
clear() { clear() {
this.value(''); this.state.clear();
if (this.getCurrentSearch()) {
app.current.clearSearch();
} else {
m.redraw();
}
} }
/** /**

View File

@ -2,8 +2,8 @@
* The `SearchSource` interface defines a section of search results in the * The `SearchSource` interface defines a section of search results in the
* search dropdown. * search dropdown.
* *
* Search sources should be registered with the `Search` component instance * Search sources should be registered with the `Search` component class
* (app.search) 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 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.

View File

@ -0,0 +1,95 @@
import SearchState from './SearchState';
export default class GlobalSearchState extends SearchState {
constructor(cachedSearches = [], searchRoute = 'index') {
super(cachedSearches);
this.searchRoute = searchRoute;
}
getValue() {
if (this.value === undefined) {
this.value = this.getInitialSearch() || '';
}
return super.getValue();
}
/**
* Clear the search input and the current controller's active search.
*/
clear() {
super.clear();
if (this.getInitialSearch()) {
this.clearInitialSearch();
} else {
m.redraw();
}
}
/**
* Get URL parameters that stick between filter changes.
*
* @return {Object}
*/
stickyParams() {
return {
sort: m.route.param('sort'),
q: m.route.param('q'),
};
}
/**
* Get parameters to pass to the DiscussionList component.
*
* @return {Object}
*/
params() {
const params = this.stickyParams();
params.filter = m.route.param('filter');
return params;
}
/**
* Redirect to the index page using the given sort parameter.
*
* @param {String} sort
*/
changeSort(sort) {
const params = this.params();
if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) {
delete params.sort;
} else {
params.sort = sort;
}
m.route(app.route(this.searchRoute, params));
}
/**
* Return the current search query, if any. This is implemented to activate
* the search box in the header.
*
* @see Search
* @return {String}
*/
getInitialSearch() {
return app.current.constructor.providesInitialSearch && this.params().q;
}
/**
* Redirect to the index page without a search filter. This is called when the
* 'x' is clicked in the search box in the header.
*
* @see Search
*/
clearInitialSearch() {
const params = this.params();
delete params.q;
m.route(app.route(this.searchRoute, params));
}
}

View File

@ -0,0 +1,35 @@
export default class SearchState {
constructor(cachedSearches = []) {
this.cachedSearches = cachedSearches;
}
getValue() {
return this.value;
}
setValue(value) {
this.value = value;
}
/**
* Clear the search value.
*/
clear() {
this.setValue('');
}
/**
* Mark that we have already searched for this query so that we don't
* have to ping the endpoint again.
*/
cache(query) {
this.cachedSearches.push(query);
}
/**
* Check if this query has been searched before.
*/
isCached(query) {
return this.cachedSearches.indexOf(query) !== -1;
}
}