From 1b7d42bfc5d7daf7f6b8ace19b273e16973c54e7 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sat, 18 Nov 2023 11:45:54 +0100 Subject: [PATCH 01/19] feat: first iteration --- .../js/src/forum/addSubscriptionFilter.js | 2 +- extensions/tags/js/src/forum/addTagFilter.tsx | 2 +- extensions/tags/js/src/forum/addTagList.js | 2 +- .../core/js/src/admin/AdminApplication.tsx | 4 + .../core/js/src/admin/components/AdminNav.js | 17 +- .../js/src/admin/components/UserListPage.tsx | 24 +- framework/core/js/src/common/Application.tsx | 3 + framework/core/js/src/common/SearchManager.ts | 91 +++++ framework/core/js/src/common/Store.ts | 8 +- framework/core/js/src/common/common.ts | 5 + .../js/src/common/components/InfoTile.tsx | 31 ++ .../core/js/src/common/components/Input.tsx | 89 +++++ .../core/js/src/common/extenders/Search.ts | 20 +- .../query}/DiscussionsSearchSource.tsx | 30 +- .../query}/UsersSearchSource.tsx | 44 ++- .../{forum => common}/states/SearchState.ts | 0 .../core/js/src/forum/ForumApplication.tsx | 6 +- .../src/forum/components/HeaderSecondary.js | 2 +- .../js/src/forum/components/IndexPage.tsx | 8 +- .../js/src/forum/components/IndexSidebar.tsx | 2 +- .../core/js/src/forum/components/Search.tsx | 347 ++---------------- .../js/src/forum/components/SearchModal.tsx | 341 +++++++++++++++++ framework/core/js/src/forum/forum.ts | 3 - .../js/src/forum/states/GlobalSearchState.ts | 4 +- framework/core/less/admin/AdminNav.less | 1 - framework/core/less/admin/UsersListPage.less | 7 + framework/core/less/common/FormControl.less | 4 +- framework/core/less/common/InfoTile.less | 14 + framework/core/less/common/Input.less | 77 ++++ framework/core/less/common/Modal.less | 29 +- framework/core/less/common/Search.less | 136 +++---- framework/core/less/common/common.less | 2 + framework/core/less/common/root.less | 1 + framework/core/locale/core.yml | 23 +- 34 files changed, 892 insertions(+), 487 deletions(-) create mode 100644 framework/core/js/src/common/SearchManager.ts create mode 100644 framework/core/js/src/common/components/InfoTile.tsx create mode 100644 framework/core/js/src/common/components/Input.tsx rename framework/core/js/src/{forum/components => common/query}/DiscussionsSearchSource.tsx (75%) rename framework/core/js/src/{forum/components => common/query}/UsersSearchSource.tsx (63%) rename framework/core/js/src/{forum => common}/states/SearchState.ts (100%) create mode 100644 framework/core/js/src/forum/components/SearchModal.tsx create mode 100644 framework/core/less/common/InfoTile.less create mode 100644 framework/core/less/common/Input.less diff --git a/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js b/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js index 02e6d912b7..5f02e1287e 100644 --- a/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js +++ b/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js @@ -9,7 +9,7 @@ import GlobalSearchState from 'flarum/forum/states/GlobalSearchState'; export default function addSubscriptionFilter() { extend(IndexSidebar.prototype, 'navItems', function (items) { if (app.session.user) { - const params = app.search.stickyParams(); + const params = app.search.state.stickyParams(); items.add( 'following', diff --git a/extensions/tags/js/src/forum/addTagFilter.tsx b/extensions/tags/js/src/forum/addTagFilter.tsx index 2c321247c4..34fee46280 100644 --- a/extensions/tags/js/src/forum/addTagFilter.tsx +++ b/extensions/tags/js/src/forum/addTagFilter.tsx @@ -20,7 +20,7 @@ export default function addTagFilter() { return this.currentActiveTag; } - const slug = this.search.params().tags; + const slug = this.search.state.params().tags; let tag = null; if (slug) { diff --git a/extensions/tags/js/src/forum/addTagList.js b/extensions/tags/js/src/forum/addTagList.js index 590aae3d4d..a26c9ae8f5 100644 --- a/extensions/tags/js/src/forum/addTagList.js +++ b/extensions/tags/js/src/forum/addTagList.js @@ -24,7 +24,7 @@ export default function addTagList() { items.add('separator', , -12); - const params = app.search.stickyParams(); + const params = app.search.state.stickyParams(); const tags = app.store.all('tags'); const currentTag = app.currentTag(); diff --git a/framework/core/js/src/admin/AdminApplication.tsx b/framework/core/js/src/admin/AdminApplication.tsx index 751a9a3fb2..2098d3847e 100644 --- a/framework/core/js/src/admin/AdminApplication.tsx +++ b/framework/core/js/src/admin/AdminApplication.tsx @@ -6,6 +6,8 @@ import Navigation from '../common/components/Navigation'; import AdminNav from './components/AdminNav'; import ExtensionData from './utils/ExtensionData'; import IHistory from '../common/IHistory'; +import SearchManager from '../common/SearchManager'; +import SearchState from '../common/states/SearchState'; export type Extension = { id: string; @@ -66,6 +68,8 @@ export default class AdminApplication extends Application { home: () => {}, }; + search: SearchManager = new SearchManager(new SearchState()); + /** * Settings are serialized to the admin dashboard as strings. * Additional encoding/decoding is possible, but must take diff --git a/framework/core/js/src/admin/components/AdminNav.js b/framework/core/js/src/admin/components/AdminNav.js index 3972bc4140..bc0f64c70b 100644 --- a/framework/core/js/src/admin/components/AdminNav.js +++ b/framework/core/js/src/admin/components/AdminNav.js @@ -6,6 +6,8 @@ import SelectDropdown from '../../common/components/SelectDropdown'; import getCategorizedExtensions from '../utils/getCategorizedExtensions'; import ItemList from '../../common/utils/ItemList'; import Stream from '../../common/utils/Stream'; +import Input from '../../common/components/Input'; +import extractText from '../../common/utils/extractText'; export default class AdminNav extends Component { oninit(vnode) { @@ -122,14 +124,13 @@ export default class AdminNav extends Component { items.add( 'search', -
- -
, + , 0 ); diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index 8cd8ff4da7..14e5cd615e 100644 --- a/framework/core/js/src/admin/components/UserListPage.tsx +++ b/framework/core/js/src/admin/components/UserListPage.tsx @@ -17,6 +17,7 @@ import AdminPage from './AdminPage'; import { debounce } from '../../common/utils/throttleDebounce'; import CreateUserModal from './CreateUserModal'; import Icon from '../../common/components/Icon'; +import Input from '../../common/components/Input'; type ColumnData = { /** @@ -236,18 +237,17 @@ export default class UserListPage extends AdminPage { items.add( 'search', -
- { - this.isLoadingPage = true; - this.query = (e?.target as HTMLInputElement)?.value; - this.throttledSearch(); - }} - /> -
, + { + this.isLoadingPage = true; + this.query = value; + this.throttledSearch(); + }} + />, 100 ); diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx index bccc69b183..72081f7562 100644 --- a/framework/core/js/src/common/Application.tsx +++ b/framework/core/js/src/common/Application.tsx @@ -37,6 +37,7 @@ import fireApplicationError from './helpers/fireApplicationError'; import IHistory from './IHistory'; import IExtender from './extenders/IExtender'; import AccessToken from './models/AccessToken'; +import SearchManager from './SearchManager'; export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd'; @@ -184,6 +185,8 @@ export default class Application { notifications: Notification, }); + search!: SearchManager; + /** * A local cache that can be used to store data at the application level, so * that is persists between different routes. diff --git a/framework/core/js/src/common/SearchManager.ts b/framework/core/js/src/common/SearchManager.ts new file mode 100644 index 0000000000..60ebe43dd9 --- /dev/null +++ b/framework/core/js/src/common/SearchManager.ts @@ -0,0 +1,91 @@ +import SearchState from './states/SearchState'; +import type Mithril from 'mithril'; +import GambitManager from './GambitManager'; +import DiscussionsSearchSource from './query/DiscussionsSearchSource'; +import UsersSearchSource from './query/UsersSearchSource'; +import ItemList from './utils/ItemList'; +import app from '../forum/app'; + +/** + * 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 { + /** + * The resource type that this search source is responsible for. + */ + resource: string; + + /** + * Get the title for this search source. + */ + title(): string; + + /** + * Check if a query has been cached for this search source. + */ + isCached(query: string): boolean; + + /** + * 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; + + /** + * Get an array of virtual
  • s that list the search results for the given + * query. + */ + view(query: string): Array; + + /** + * Get a list item for the full search results page. + */ + fullPage(query: string): Mithril.Vnode | null; +} + +export default class SearchManager { + /** + * The minimum query length before sources are searched. + */ + public static MIN_SEARCH_LEN = 3; + + /** + * An object which stores previously searched queries and provides convenient + * tools for retrieving and managing search values. + */ + public state: State; + + /** + * The gambit manager that will convert search query gambits + * into API filters. + */ + public gambits = new GambitManager(); + + constructor(state: State) { + this.state = state; + } + + /** + * A list of search sources that can be used to query for search results. + */ + sources(): ItemList { + const items = new ItemList(); + + if (app.forum.attribute('canViewForum')) { + items.add('discussions', new DiscussionsSearchSource()); + } + + if (app.forum.attribute('canSearchUsers')) { + items.add('users', new UsersSearchSource()); + } + + return items; + } +} diff --git a/framework/core/js/src/common/Store.ts b/framework/core/js/src/common/Store.ts index f1d9e8784e..09051d18aa 100644 --- a/framework/core/js/src/common/Store.ts +++ b/framework/core/js/src/common/Store.ts @@ -89,12 +89,6 @@ export default class Store { */ models: Record; - /** - * The gambit manager that will convert search query gambits - * into API filters. - */ - gambits = new GambitManager(); - constructor(models: Record) { this.models = models; } @@ -186,7 +180,7 @@ export default class Store { } if ('filter' in params && params?.filter?.q) { - params.filter = this.gambits.apply(type, params.filter); + params.filter = app.search.gambits.apply(type, params.filter); } return app diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index 13c450dc53..fbc8713849 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -5,6 +5,7 @@ import './states/PaginatedListState'; import './states/AlertManagerState'; import './states/ModalManagerState'; import './states/PageState'; +import './states/SearchState'; import './utils/isObject'; import './utils/mixin'; @@ -50,6 +51,7 @@ import './components/LoadingIndicator'; import './components/Placeholder'; import './components/Separator'; import './components/Dropdown'; +import './components/InfoTile'; import './components/DetailedDropdownItem'; import './components/SplitDropdown'; import './components/RequestErrorModal'; @@ -81,6 +83,9 @@ import './helpers/userOnline'; import './helpers/listItems'; import './helpers/textContrastClass'; +import './query/DiscussionsSearchSource'; +import './query/UsersSearchSource'; + import './resolvers/DefaultResolver'; import './Component'; diff --git a/framework/core/js/src/common/components/InfoTile.tsx b/framework/core/js/src/common/components/InfoTile.tsx new file mode 100644 index 0000000000..7e70f81292 --- /dev/null +++ b/framework/core/js/src/common/components/InfoTile.tsx @@ -0,0 +1,31 @@ +import Component from '../Component'; +import type { ComponentAttrs } from '../Component'; +import type Mithril from 'mithril'; +import Icon from './Icon'; +import classList from '../utils/classList'; + +export interface IInfoTileAttrs extends ComponentAttrs { + icon?: string; + iconElement?: Mithril.Children; +} + +export default class InfoTile extends Component { + view(vnode: Mithril.Vnode): Mithril.Children { + const { icon, className, ...attrs } = vnode.attrs; + + return ( +
    + {this.icon()} +
    {vnode.children}
    +
    + ); + } + + icon(): Mithril.Children { + if (this.attrs.iconElement) return this.attrs.iconElement; + + if (!this.attrs.icon) return null; + + return ; + } +} diff --git a/framework/core/js/src/common/components/Input.tsx b/framework/core/js/src/common/components/Input.tsx new file mode 100644 index 0000000000..5c5f75d4ec --- /dev/null +++ b/framework/core/js/src/common/components/Input.tsx @@ -0,0 +1,89 @@ +import app from '../../forum/app'; +import Component from '../Component'; +import Icon from './Icon'; +import LoadingIndicator from './LoadingIndicator'; +import classList from '../utils/classList'; +import Button from './Button'; +import Stream from '../utils/Stream'; +import type { ComponentAttrs } from '../Component'; +import type Mithril from 'mithril'; + +export interface IInputAttrs extends ComponentAttrs { + className?: string; + prefixIcon?: string; + clearable?: boolean; + clearLabel?: string; + loading?: boolean; + inputClassName?: string; + onchange?: (value: string) => void; + value?: string; + stream?: Stream; + type?: string; + ariaLabel?: string; + placeholder?: string; + readonly?: boolean; + disabled?: boolean; + inputAttrs?: { + className?: string; + [key: string]: any; + }; +} + +export default class Input extends Component { + protected value!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + this.value = Stream(this.attrs.value || this.attrs.stream?.() || ''); + } + + view(vnode: Mithril.Vnode): Mithril.Children { + const { className: inputClassName, ...inputAttrs } = this.attrs.inputAttrs || {}; + + return ( +
    + {this.attrs.prefixIcon && } + this.onchange?.((e.target as HTMLInputElement).value)} + aria-label={this.attrs.ariaLabel} + placeholder={this.attrs.placeholder} + readonly={this.attrs.readonly || undefined} + disabled={this.attrs.disabled || undefined} + {...inputAttrs} + /> + {this.attrs.loading && } + {this.attrs.clearable && this.value() && !this.attrs.loading && ( +
    + ); + } + + onchange(value: string) { + if (this.attrs.stream) { + this.attrs.stream(value); + } else { + this.attrs.onchange?.(value); + } + + this.value(value); + } + + clear() { + this.onchange(''); + } +} diff --git a/framework/core/js/src/common/extenders/Search.ts b/framework/core/js/src/common/extenders/Search.ts index c2c54cc0b8..9484e007da 100644 --- a/framework/core/js/src/common/extenders/Search.ts +++ b/framework/core/js/src/common/extenders/Search.ts @@ -2,9 +2,12 @@ import type IExtender from './IExtender'; import type { IExtensionModule } from './IExtender'; import type Application from '../Application'; import IGambit from '../query/IGambit'; +import SearchManager, { SearchSource } from '../SearchManager'; +import { extend } from '../extend'; export default class Search implements IExtender { protected gambits: Record IGambit>> = {}; + protected sources: Record SearchSource> = {}; public gambit(modelType: string, gambit: new () => IGambit): this { this.gambits[modelType] = this.gambits[modelType] || []; @@ -13,12 +16,25 @@ export default class Search implements IExtender { return this; } + public source(modelType: string, source: new () => SearchSource): this { + this.sources[modelType] = source; + + return this; + } + extend(app: Application, extension: IExtensionModule): void { for (const [modelType, gambits] of Object.entries(this.gambits)) { for (const gambit of gambits) { - app.store.gambits.gambits[modelType] = app.store.gambits.gambits[modelType] || []; - app.store.gambits.gambits[modelType].push(gambit); + app.search.gambits.gambits[modelType] = app.search.gambits.gambits[modelType] || []; + app.search.gambits.gambits[modelType].push(gambit); } } + + const sources = this.sources; + extend(SearchManager.prototype, 'sources', function (items) { + for (const [modelType, source] of Object.entries(sources)) { + items.add(modelType, new source()); + } + }); } } diff --git a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx b/framework/core/js/src/common/query/DiscussionsSearchSource.tsx similarity index 75% rename from framework/core/js/src/forum/components/DiscussionsSearchSource.tsx rename to framework/core/js/src/common/query/DiscussionsSearchSource.tsx index 84de850396..1dfca5c933 100644 --- a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/common/query/DiscussionsSearchSource.tsx @@ -2,9 +2,10 @@ import app from '../../forum/app'; import highlight from '../../common/helpers/highlight'; import LinkButton from '../../common/components/LinkButton'; import Link from '../../common/components/Link'; -import { SearchSource } from './Search'; import type Mithril from 'mithril'; import Discussion from '../../common/models/Discussion'; +import type { SearchSource } from '../../common/SearchManager'; +import extractText from '../utils/extractText'; /** * The `DiscussionsSearchSource` finds and displays discussion search results in @@ -13,6 +14,16 @@ import Discussion from '../../common/models/Discussion'; export default class DiscussionsSearchSource implements SearchSource { 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): Promise { query = query.toLowerCase(); @@ -48,19 +59,20 @@ export default class DiscussionsSearchSource implements SearchSource { ); }) as Array; - const filter = app.store.gambits.apply('discussions', { q: query }); - const q = filter.q || null; + return results; + } + 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.forum.search.discussions_heading')}
  • , + return (
  • - {app.translator.trans('core.forum.search.all_discussions_button', { query })} + {app.translator.trans('core.lib.search_source.discussions.all_button', { query })} -
  • , - ...results, - ]; + + ); } } diff --git a/framework/core/js/src/forum/components/UsersSearchSource.tsx b/framework/core/js/src/common/query/UsersSearchSource.tsx similarity index 63% rename from framework/core/js/src/forum/components/UsersSearchSource.tsx rename to framework/core/js/src/common/query/UsersSearchSource.tsx index 1ae165bc62..74fa6744a8 100644 --- a/framework/core/js/src/forum/components/UsersSearchSource.tsx +++ b/framework/core/js/src/common/query/UsersSearchSource.tsx @@ -4,9 +4,10 @@ import app from '../../forum/app'; import highlight from '../../common/helpers/highlight'; import username from '../../common/helpers/username'; import Link from '../../common/components/Link'; -import { SearchSource } from './Search'; import User from '../../common/models/User'; import Avatar from '../../common/components/Avatar'; +import type { SearchSource } from '../SearchManager'; +import extractText from '../utils/extractText'; /** * The `UsersSearchSource` finds and displays user search results in the search @@ -15,6 +16,16 @@ import Avatar from '../../common/components/Avatar'; 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): Promise { return app.store .find('users', { @@ -41,20 +52,21 @@ export default class UsersSearchResults implements SearchSource { if (!results.length) return []; - return [ -
  • {app.translator.trans('core.forum.search.users_heading')}
  • , - ...results.map((user) => { - const name = username(user, (name: string) => highlight(name, query)); - - return ( -
  • - - - {name} - -
  • - ); - }), - ]; + return results.map((user) => { + const name = username(user, (name: string) => highlight(name, query)); + + return ( +
  • + + + {name} + +
  • + ); + }); + } + + fullPage(query: string): null { + return null; } } diff --git a/framework/core/js/src/forum/states/SearchState.ts b/framework/core/js/src/common/states/SearchState.ts similarity index 100% rename from framework/core/js/src/forum/states/SearchState.ts rename to framework/core/js/src/common/states/SearchState.ts diff --git a/framework/core/js/src/forum/ForumApplication.tsx b/framework/core/js/src/forum/ForumApplication.tsx index c37d0c1395..4bd195ca15 100644 --- a/framework/core/js/src/forum/ForumApplication.tsx +++ b/framework/core/js/src/forum/ForumApplication.tsx @@ -25,6 +25,7 @@ import type PostModel from '../common/models/Post'; import extractText from '../common/utils/extractText'; import Notices from './components/Notices'; import Footer from './components/Footer'; +import SearchManager from '../common/SearchManager'; export interface ForumApplicationData extends ApplicationData {} @@ -61,10 +62,9 @@ export default class ForumApplication extends Application { notifications: NotificationListState = new NotificationListState(); /** - * An object which stores previously searched queries and provides convenient - * tools for retrieving and managing search values. + * An object which stores the global search state and manages search capabilities. */ - search: GlobalSearchState = new GlobalSearchState(); + search: SearchManager = new SearchManager(new GlobalSearchState()); /** * An object which controls the state of the composer. diff --git a/framework/core/js/src/forum/components/HeaderSecondary.js b/framework/core/js/src/forum/components/HeaderSecondary.js index 95806cb5dd..56a929439f 100644 --- a/framework/core/js/src/forum/components/HeaderSecondary.js +++ b/framework/core/js/src/forum/components/HeaderSecondary.js @@ -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/IndexPage.tsx b/framework/core/js/src/forum/components/IndexPage.tsx index f236d502ea..d72bf4dce7 100644 --- a/framework/core/js/src/forum/components/IndexPage.tsx +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -41,7 +41,7 @@ export default class IndexPage sortOptions[key])[0]} + label={sortOptions[app.search.state.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0]} accessibleToggleLabel={app.translator.trans('core.forum.index_sort.toggle_dropdown_accessible_label')} > {Object.keys(sortOptions).map((value) => { const label = sortOptions[value]; - const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value; + const active = (app.search.state.params().sort || Object.keys(sortMap)[0]) === value; return ( - ); diff --git a/framework/core/js/src/forum/components/IndexSidebar.tsx b/framework/core/js/src/forum/components/IndexSidebar.tsx index 242ad9fd7e..b7fe99d88f 100644 --- a/framework/core/js/src/forum/components/IndexSidebar.tsx +++ b/framework/core/js/src/forum/components/IndexSidebar.tsx @@ -65,7 +65,7 @@ export default class IndexSidebar(); - const params = app.search.stickyParams(); + const params = app.search.state.stickyParams(); items.add( 'allDiscussions', diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index c77744f0d3..ef3cddedb1 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -1,39 +1,10 @@ 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 SearchState from '../states/SearchState'; -import DiscussionsSearchSource from './DiscussionsSearchSource'; -import UsersSearchSource from './UsersSearchSource'; +import Input from '../../common/components/Input'; +import SearchState from '../../common/states/SearchState'; +import SearchModal from './SearchModal'; import type Mithril from 'mithril'; -import Icon from '../../common/components/Icon'; - -/** - * 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; - - /** - * 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}`. */ @@ -53,45 +24,11 @@ export interface SearchAttrs extends ComponentAttrs { * - 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); @@ -99,270 +36,32 @@ export default class Search extends Compone } 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
    ; + if (app.search.sources().isEmpty()) 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()))} -
    +
    + + setTimeout(() => { + this.$('input').blur() && app.modal.show(SearchModal, { searchState: this.searchState }); + }, 150), + }} + // onchange={(value: string) => this.searchState.setValue(value)} + />
    ); } - - 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()); - 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); - } - - 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); - } - } - } } diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx new file mode 100644 index 0000000000..11e55a6826 --- /dev/null +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -0,0 +1,341 @@ +import app from '../app'; +import FormModal from '../../common/components/FormModal'; +import type { IFormModalAttrs } from '../../common/components/FormModal'; +import type Mithril from 'mithril'; +import type SearchState from '../../common/states/SearchState'; +import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable'; +import SearchManager, { SearchSource } from '../../common/SearchManager'; +import extractText from '../../common/utils/extractText'; +import Input from '../../common/components/Input'; +import Button from '../../common/components/Button'; +import Stream from '../../common/utils/Stream'; +import Icon from '../../common/components/Icon'; +import InfoTile from '../../common/components/InfoTile'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; + +export interface ISearchModalAttrs extends IFormModalAttrs { + onchange: (value: string) => void; + searchState: SearchState; +} + +export default class SearchModal extends FormModal { + protected searchState!: SearchState; + + /** + * An array of SearchSources. + */ + protected sources?: SearchSource[]; + + /** + * The key of the currently-active search source. + */ + protected activeSource!: Stream; + + /** + * The sources that are still loading results. + */ + protected loadingSources: string[] = []; + + /** + * 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; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.searchState = this.attrs.searchState; + } + + title(): Mithril.Children { + return app.translator.trans('core.forum.search.title'); + } + + className(): string { + return 'SearchModal Modal--flat'; + } + + content(): Mithril.Children { + // Initialize search sources in the view rather than the constructor so + // that we have access to app.forum. + if (!this.sources) this.sources = app.search.sources().toArray(); + + // Hide the search view if no sources were loaded + if (!this.sources.length) return
    ; + + // Initialize the active source. + if (!this.activeSource) this.activeSource = Stream(this.sources[0]); + + const searchLabel = extractText(app.translator.trans('core.forum.search.placeholder')); + + return ( +
    +
    + this.searchState.setValue(value)} + inputAttrs={{ className: 'SearchModal-input' }} + /> +
    + {this.tabs()} +
    + ); + } + + tabs(): JSX.Element { + return ( +
    +
    + {this.sources?.map((source) => ( + + ))} +
    + {this.activeTab()} +
    + ); + } + + activeTab(): JSX.Element { + const shouldShowResults = this.searchState.getValue(); + const shouldShowOptions = false; + const fullPageLink = this.activeSource().fullPage(this.searchState.getValue()); + const results = this.activeSource()?.view(this.searchState.getValue()); + + return ( +
    + {fullPageLink && ( +
    +
    +
      {fullPageLink}
    +
    + )} + {shouldShowOptions && ( +
    +
      +
    • {app.translator.trans('core.forum.search.options_heading')}
    • +
    +
    + )} +
    +
    +
      +
    • {app.translator.trans('core.forum.search.preview_heading')}
    • + {!this.searchState.getValue() && ( +
    • + {app.translator.trans('core.forum.search.no_search_text')} +
    • + )} + {shouldShowResults && results} + {shouldShowResults && !results?.length && ( +
    • + {app.translator.trans('core.forum.search.no_results_text')} +
    • + )} + {this.loadingSources.includes(this.activeSource().resource) && ( +
    • + +
    • + )} +
    +
    +
    + ); + } + + 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; + } + + 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 component = this; + const search = this.search.bind(this); + + // Highlight the item that is currently selected. + this.setIndex(this.getCurrentNumericIndex()); + + this.$('.SearchModal-results') + // Whenever the mouse is hovered over a search result, highlight it. + .on('mouseenter', '> li:not(.Dropdown-header)', function () { + component.setIndex(component.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 () { + search(this.value.toLowerCase()); + }) + + .on('focus', function () { + $(this) + .one('mouseup', (e) => e.preventDefault()) + .trigger('select'); + }); + } + + search(query: string) { + if (!query) return; + + const source = this.activeSource(); + + if (this.searchTimeout) clearTimeout(this.searchTimeout); + + this.searchTimeout = window.setTimeout(() => { + if (source.isCached(query)) return; + + if (query.length >= SearchManager.MIN_SEARCH_LEN) { + if (!source.search) return; + + this.loadingSources.push(source.resource); + + source.search(query).then(() => { + this.loadingSources = this.loadingSources.filter((resource) => resource !== source.resource); + m.redraw(); + }); + } + + this.searchState.cache(query); + m.redraw(); + }, 250); + } + + /** + * Navigate to the currently selected search result and close the list. + */ + selectResult() { + if (this.searchTimeout) clearTimeout(this.searchTimeout); + + this.loadingSources = []; + + 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(); + } + + /** + * Get all of the search result items that are selectable. + */ + selectableItems(): JQuery { + return this.$('.SearchModal-results > li:not(.Dropdown-header):not(.Dropdown-message)'); + } + + /** + * 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); + } + + 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); + } + } + } +} diff --git a/framework/core/js/src/forum/forum.ts b/framework/core/js/src/forum/forum.ts index 7a74d158b0..9d10c501fb 100644 --- a/framework/core/js/src/forum/forum.ts +++ b/framework/core/js/src/forum/forum.ts @@ -12,7 +12,6 @@ import './states/DiscussionListState'; import './states/GlobalSearchState'; import './states/NotificationListState'; import './states/PostStreamState'; -import './states/SearchState'; import './components/AffixedSidebar'; import './components/DiscussionPage'; import './components/DiscussionListPane'; @@ -23,7 +22,6 @@ import './components/HeaderPrimary'; import './components/PostEdited'; import './components/IndexPage'; import './components/DiscussionRenamedNotification'; -import './components/DiscussionsSearchSource'; import './components/HeaderSecondary'; import './components/DiscussionList'; import './components/AvatarEditor'; @@ -33,7 +31,6 @@ import './components/NotificationsDropdown'; import './components/UserPage'; import './components/PostUser'; import './components/UserCard'; -import './components/UsersSearchSource'; import './components/PostPreview'; import './components/EventPost'; import './components/DiscussionHero'; diff --git a/framework/core/js/src/forum/states/GlobalSearchState.ts b/framework/core/js/src/forum/states/GlobalSearchState.ts index b4ca4e5c26..edfcd68c1c 100644 --- a/framework/core/js/src/forum/states/GlobalSearchState.ts +++ b/framework/core/js/src/forum/states/GlobalSearchState.ts @@ -1,6 +1,6 @@ import app from '../../forum/app'; import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefresh'; -import SearchState from './SearchState'; +import SearchState from '../../common/states/SearchState'; type SearchParams = Record; @@ -43,7 +43,7 @@ export default class GlobalSearchState extends SearchState { const q = this.params().q || ''; const filter = this.params().filter || {}; - return app.store.gambits.from('users', app.store.gambits.from('discussions', q, filter), filter).trim(); + return app.search.gambits.from('users', app.search.gambits.from('discussions', q, filter), filter).trim(); } /** diff --git a/framework/core/less/admin/AdminNav.less b/framework/core/less/admin/AdminNav.less index 91ca57f6fc..3dcbb70c8a 100644 --- a/framework/core/less/admin/AdminNav.less +++ b/framework/core/less/admin/AdminNav.less @@ -146,7 +146,6 @@ font-weight: normal; } - .Search-input, .SearchBar { max-width: 215px; margin: 0 auto; diff --git a/framework/core/less/admin/UsersListPage.less b/framework/core/less/admin/UsersListPage.less index dd0cd61850..54ee110926 100644 --- a/framework/core/less/admin/UsersListPage.less +++ b/framework/core/less/admin/UsersListPage.less @@ -4,6 +4,13 @@ &-header { margin-bottom: 16px; + display: flex; + align-items: center; + column-gap: 8px; + } + + &-totalUsers { + margin: 0; } &-actions { diff --git a/framework/core/less/common/FormControl.less b/framework/core/less/common/FormControl.less index 1216a66719..b5eded09db 100644 --- a/framework/core/less/common/FormControl.less +++ b/framework/core/less/common/FormControl.less @@ -5,8 +5,8 @@ padding: 8px 13px; font-size: 13px; line-height: 1.5; - color: var(--control-color); - background-color: var(--control-bg); + color: var(--form-control-color, var(--control-color)); + background-color: var(--form-control-bg, var(--control-bg)); border: 2px solid transparent; border-radius: var(--border-radius); transition: var(--transition); diff --git a/framework/core/less/common/InfoTile.less b/framework/core/less/common/InfoTile.less new file mode 100644 index 0000000000..8c2cbad5da --- /dev/null +++ b/framework/core/less/common/InfoTile.less @@ -0,0 +1,14 @@ +.InfoTile { + display: flex; + flex-direction: column; + row-gap: 24px; + font-size: 1.1rem; + color: var(--control-color); + align-items: center; + padding: 8px 0; + + .icon { + color: var(--control-muted-color); + font-size: 2rem; + } +} diff --git a/framework/core/less/common/Input.less b/framework/core/less/common/Input.less new file mode 100644 index 0000000000..a19830033a --- /dev/null +++ b/framework/core/less/common/Input.less @@ -0,0 +1,77 @@ +.Input { + position: relative; + overflow: hidden; + color: var(--muted-color); + + &-prefix-icon { + margin-right: -36px; + width: 36px; + font-size: 14px; + text-align: center; + position: absolute; + line-height: 1.5; + pointer-events: none; + } + + .FormControl { + transition: var(--transition), width 0.4s; + box-sizing: inherit !important; + + &[readonly] { + opacity: 1; + } + } + + &--withPrefix { + .FormControl { + padding-left: 32px; + } + } + + &-clear { + // It looks very weird due to the padding given to the button.. + &:focus { + outline: none; + } + + // ...so we display the ring around the icon inside the button, with an offset + .add-keyboard-focus-ring-nearby("> *"); + .add-keyboard-focus-ring-nearby-offset("> *", 4px); + } + + &--withClear { + // TODO v2.0 check if this is supported by Firefox, + // if so, consider switching to it. + ::-webkit-search-cancel-button { + display: none; + } + + .FormControl { + padding-right: 32px; + } + } + + .LoadingIndicator-container { + height: 100%; + } + + .Button, &-prefix-icon { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .Button { + position: absolute; + right: 0; + top: 0; + margin-left: -36px; + width: 36px !important; + + &.LoadingIndicator { + width: var(--size) !important; + padding: 0; + } + } +} diff --git a/framework/core/less/common/Modal.less b/framework/core/less/common/Modal.less index 3e9b7014ab..4dfd6cd5eb 100644 --- a/framework/core/less/common/Modal.less +++ b/framework/core/less/common/Modal.less @@ -94,8 +94,8 @@ color: var(--control-color); .FormControl { - background-color: var(--body-bg); - color: var(--text-color); + --form-control-bg: var(--body-bg); + --form-control-color: var(--text-color); } .Form--centered { @@ -117,6 +117,12 @@ color: var(--muted-color); } +.Modal-divider { + border-width: 1px; + margin-left: -16px; + margin-right: -16px; +} + .Modal--inverted { .Modal-header { background-color: var(--control-bg); @@ -128,6 +134,25 @@ } } +.Modal--flat { + .Modal-header { + background-color: transparent; + text-align: start; + padding: 18px 18px 0 18px; + color: var(--control-color); + } + .Modal-body { + background-color: transparent; + color: unset; + padding: 18px 16px; + + .FormControl:not(:focus) { + --form-control-bg: var(--control-bg); + --form-control-color: var(--control-color); + } + } +} + @media @phone { .ModalManager { position: fixed; diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index 7e72fc3b77..0af9098e17 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -1,105 +1,75 @@ .Search { position: relative; +} - // TODO v2.0 check if this is supported by Firefox, - // if so, consider switching to it. - ::-webkit-search-cancel-button { - display: none; +.DiscussionSearchResult-excerpt { + margin-top: 3px; + color: var(--muted-color); + font-size: 11px; +} +.UserSearchResult .Avatar { + .Avatar--size(24px); + margin: -2px 10px -2px 0; +} + +.SearchModal { + &-body { + display: flex; + flex-direction: column; + row-gap: 6px; } - &-clear { - // It looks very weird due to the padding given to the button.. - &:focus { - outline: none; + &-tabs { + &-nav + .Modal-divider { + margin-top: 0; } - // ...so we display the ring around the icon inside the button, with an offset - .add-keyboard-focus-ring-nearby("> *"); - .add-keyboard-focus-ring-nearby-offset("> *", 4px); - } -} -@media @tablet-up { - .Search { - transition: margin-left 0.4s; + &-nav { + margin-bottom: -1px; + display: flex; + column-gap: 4px; + padding: 0 14px; - &.focused { - margin-left: -400px; + > .Button { + border-radius: 0; + font-size: 15px; + padding: 12px 8px; + border-bottom: 2px solid; + border-color: transparent; - input, - .Search-results { - width: 400px; + &[active] { + --button-color: var(--text-color); + --link-color: var(--text-color); + border-color: var(--primary-color); + font-weight: bold; + } } } - } -} -.Search-results { - overflow: auto; - left: auto; - right: 0; - @media @phone { - left: 0; - } - - > li > a { - white-space: normal; - } + &-content { + .Dropdown--expanded(); - mark { - background: none; - padding: 0; - font-weight: bold; - color: inherit; - box-shadow: none; - } -} - -.Search-input { - overflow: hidden; - color: var(--muted-color); + .Dropdown-header { + color: var(--muted-more-color); + } - &-icon { - margin-right: -36px; - width: 36px; - font-size: 14px; - text-align: center; - position: absolute; - padding: 8px 0; - line-height: 1.5; - pointer-events: none; - } - input { - width: 225px; - padding-left: 32px; - padding-right: 32px; - transition: var(--transition), width 0.4s; - box-sizing: inherit !important; + > .SearchModal-section:first-of-type .Modal-divider { + margin-top: -1px; + } + } } - .LoadingIndicator-container { - height: 36px; + &-input { + height: 42px; } - .Button { - position: absolute; - right: 0; - top: 0; - margin-left: -36px; - width: 36px !important; - - &.LoadingIndicator { - width: var(--size) !important; + &-results { + mark { + background: none; padding: 0; + font-weight: bold; + color: inherit; + box-shadow: none; } } } - -.DiscussionSearchResult-excerpt { - margin-top: 3px; - color: var(--muted-color); - font-size: 11px; -} -.UserSearchResult .Avatar { - .Avatar--size(24px); - margin: -2px 10px -2px 0; -} diff --git a/framework/core/less/common/common.less b/framework/core/less/common/common.less index 32c48a8e7c..e2eb37bb83 100644 --- a/framework/core/less/common/common.less +++ b/framework/core/less/common/common.less @@ -20,6 +20,8 @@ @import "EditUserModal"; @import "Form"; @import "FormControl"; +@import "Input"; +@import "InfoTile"; @import "LoadingIndicator"; @import "Modal"; @import "Navigation"; diff --git a/framework/core/less/common/root.less b/framework/core/less/common/root.less index 3f20666248..0fad22d8eb 100644 --- a/framework/core/less/common/root.less +++ b/framework/core/less/common/root.less @@ -28,6 +28,7 @@ --control-danger-bg: @control-danger-bg; --control-danger-color: @control-danger-color; --control-body-bg-mix: mix(@control-bg, @body-bg, 50%); + --control-muted-color: lighten(@control-color, 40%); --error-color: @error-color; diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 015f790862..0f105e1b44 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -519,11 +519,14 @@ core: submit_button: => core.ref.rename title: Rename Discussion - # These translations are used by the search results dropdown list. + # These translations are used by the search modal. search: - all_discussions_button: 'Search all discussions for "{query}"' - discussions_heading: => core.ref.discussions - users_heading: => core.ref.users + title: Search + no_results_text: No results found. + no_search_text: You have not searched for anything yet. + options_heading: Search options + placeholder: Search... + preview_heading: Search preview # These translations are used in the Security page. security: @@ -667,6 +670,10 @@ core: rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds. render_failed_message: Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information. + # These translations are used in the input component. + input: + clear_button: Clear input + # These translations are used in the loading indicator component. loading_indicator: accessible_label: => core.ref.loading @@ -689,6 +696,14 @@ core: kilo_text: K mega_text: M + # These translations are used by search sources. + search_source: + discussions: + all_button: 'Search all discussions for "{query}"' + heading: => core.ref.discussions + users: + heading: => core.ref.users + # These translations are used to punctuate a series of items. series: glue_text: ", " From 71c07f0d6f96ccee90877d0b152e1c6fda0db007 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sat, 18 Nov 2023 12:37:05 +0100 Subject: [PATCH 02/19] chore: tweak --- .../core/js/src/forum/components/SearchModal.tsx | 15 ++++++++------- framework/core/less/common/Search.less | 8 ++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index 11e55a6826..1f25f13356 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -122,14 +122,15 @@ export default class SearchModal - {fullPageLink && ( + {shouldShowResults && fullPageLink && (

      {fullPageLink}
    @@ -146,7 +147,7 @@ export default class SearchModal
    • {app.translator.trans('core.forum.search.preview_heading')}
    • - {!this.searchState.getValue() && ( + {!shouldShowResults && (
    • {app.translator.trans('core.forum.search.no_search_text')}
    • @@ -157,7 +158,7 @@ export default class SearchModal{app.translator.trans('core.forum.search.no_results_text')} )} - {this.loadingSources.includes(this.activeSource().resource) && ( + {loading && (
    • @@ -191,9 +192,9 @@ export default class SearchModal li:not(.Dropdown-header)', function () { + .on('mouseenter', '> li:not(.Dropdown-header):not(.Dropdown-message)', function () { component.setIndex(component.selectableItems().index(this)); }); @@ -275,7 +276,7 @@ export default class SearchModal li:not(.Dropdown-header):not(.Dropdown-message)'); + return this.$('.Dropdown-menu > li:not(.Dropdown-header):not(.Dropdown-message)'); } /** diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index 0af9098e17..5696de70e5 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -72,4 +72,12 @@ box-shadow: none; } } + + &-fullPage .LinkButton { + font-weight: bold; + } + + .Dropdown-menu>li>a, .Dropdown-menu>li>button, .Dropdown-menu>li>span { + border-radius: var(--border-radius); + } } From 74dc0724b28a57b1f746b069cdb5d9a1282c511a Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sat, 18 Nov 2023 13:44:22 +0100 Subject: [PATCH 03/19] feat: second iteration --- framework/core/js/src/common/SearchManager.ts | 2 +- .../common/query/DiscussionsSearchSource.tsx | 18 +++----- .../js/src/common/query/UsersSearchSource.tsx | 4 +- .../src/common/utils/KeyboardNavigatable.ts | 31 ++++++++++--- .../forum/components/DiscussionListItem.tsx | 2 +- .../components/MinimalDiscussionListItem.tsx | 45 +++++++++++++++++++ .../js/src/forum/components/SearchModal.tsx | 33 +++++++------- framework/core/less/common/Search.less | 25 +++++------ .../core/less/forum/DiscussionListItem.less | 41 +++++++++++++++++ 9 files changed, 146 insertions(+), 55 deletions(-) create mode 100644 framework/core/js/src/forum/components/MinimalDiscussionListItem.tsx diff --git a/framework/core/js/src/common/SearchManager.ts b/framework/core/js/src/common/SearchManager.ts index 60ebe43dd9..f1e042b0fd 100644 --- a/framework/core/js/src/common/SearchManager.ts +++ b/framework/core/js/src/common/SearchManager.ts @@ -36,7 +36,7 @@ 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; + search(query: string, limit: number): Promise; /** * Get an array of virtual
    • s that list the search results for the given diff --git a/framework/core/js/src/common/query/DiscussionsSearchSource.tsx b/framework/core/js/src/common/query/DiscussionsSearchSource.tsx index 1dfca5c933..39397b234c 100644 --- a/framework/core/js/src/common/query/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/common/query/DiscussionsSearchSource.tsx @@ -6,6 +6,7 @@ import type Mithril from 'mithril'; import Discussion from '../../common/models/Discussion'; import type { SearchSource } from '../../common/SearchManager'; import extractText from '../utils/extractText'; +import MinimalDiscussionListItem from '../../forum/components/MinimalDiscussionListItem'; /** * The `DiscussionsSearchSource` finds and displays discussion search results in @@ -24,14 +25,14 @@ export default class DiscussionsSearchSource implements SearchSource { return this.results.has(query.toLowerCase()); } - async search(query: string): Promise { + async search(query: string, limit: number): Promise { query = query.toLowerCase(); this.results.set(query, []); const params = { filter: { q: query }, - page: { limit: 3 }, + page: { limit }, include: 'mostRelevantPost', }; @@ -44,22 +45,13 @@ export default class DiscussionsSearchSource implements SearchSource { view(query: string): Array { query = query.toLowerCase(); - const results = (this.results.get(query) || []).map((discussion) => { - const mostRelevantPost = discussion.mostRelevantPost(); - + return (this.results.get(query) || []).map((discussion) => { return (
    • - -
      {highlight(discussion.title(), query)}
      - {!!mostRelevantPost && ( -
      {highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}
      - )} - +
    • ); }) as Array; - - return results; } fullPage(query: string): Mithril.Vnode { diff --git a/framework/core/js/src/common/query/UsersSearchSource.tsx b/framework/core/js/src/common/query/UsersSearchSource.tsx index 74fa6744a8..adc9c64f63 100644 --- a/framework/core/js/src/common/query/UsersSearchSource.tsx +++ b/framework/core/js/src/common/query/UsersSearchSource.tsx @@ -26,11 +26,11 @@ export default class UsersSearchResults implements SearchSource { return this.results.has(query.toLowerCase()); } - async search(query: string): Promise { + async search(query: string, limit: number): Promise { return app.store .find('users', { filter: { q: query }, - page: { limit: 5 }, + page: { limit }, }) .then((results) => { this.results.set(query, results); diff --git a/framework/core/js/src/common/utils/KeyboardNavigatable.ts b/framework/core/js/src/common/utils/KeyboardNavigatable.ts index 667ac10b50..4966a836c2 100644 --- a/framework/core/js/src/common/utils/KeyboardNavigatable.ts +++ b/framework/core/js/src/common/utils/KeyboardNavigatable.ts @@ -38,12 +38,7 @@ export default class KeyboardNavigatable { * This will be triggered by the Up key. */ onUp(callback: KeyboardEventHandler): KeyboardNavigatable { - this.callbacks.set(Keys.ArrowUp, (e) => { - e.preventDefault(); - callback(e); - }); - - return this; + return this.onDirection(callback, Keys.ArrowUp); } /** @@ -52,7 +47,29 @@ export default class KeyboardNavigatable { * This will be triggered by the Down key. */ onDown(callback: KeyboardEventHandler): KeyboardNavigatable { - this.callbacks.set(Keys.ArrowDown, (e) => { + return this.onDirection(callback, Keys.ArrowDown); + } + + /** + * Provide a callback to be executed when navigating leftwards. + * + * This will be triggered by the Left key. + */ + onLeft(callback: KeyboardEventHandler): KeyboardNavigatable { + return this.onDirection(callback, Keys.ArrowLeft); + } + + /** + * Provide a callback to be executed when navigating rightwards. + * + * This will be triggered by the Right key. + */ + onRight(callback: KeyboardEventHandler): KeyboardNavigatable { + return this.onDirection(callback, Keys.ArrowRight); + } + + onDirection(callback: KeyboardEventHandler, direction: Keys): KeyboardNavigatable { + this.callbacks.set(direction, (e) => { e.preventDefault(); callback(e); }); diff --git a/framework/core/js/src/forum/components/DiscussionListItem.tsx b/framework/core/js/src/forum/components/DiscussionListItem.tsx index fd5decba8e..0fa5a91683 100644 --- a/framework/core/js/src/forum/components/DiscussionListItem.tsx +++ b/framework/core/js/src/forum/components/DiscussionListItem.tsx @@ -54,7 +54,7 @@ export default class DiscussionListItem { + elementAttrs() { + const attrs = super.elementAttrs(); + + attrs.className = classList(attrs.className, 'MinimalDiscussionListItem'); + + return attrs; + } + + viewItems(): ItemList { + return super.viewItems().remove('controls').remove('slidableUnderneath'); + } + + contentItems(): ItemList { + return super.contentItems().remove('stats'); + } + + authorItems(): ItemList { + return super.authorItems().remove('badges'); + } + + mainView(): Mithril.Children { + const discussion = this.attrs.discussion; + const jumpTo = this.getJumpTo(); + + return ( + +

      + {this.badgesView()} +
      {highlight(discussion.title(), this.highlightRegExp)}
      +

      +
        {listItems(this.infoItems().toArray())}
      + + ); + } +} diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index 1f25f13356..fb85db87e8 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -9,7 +9,6 @@ import extractText from '../../common/utils/extractText'; import Input from '../../common/components/Input'; import Button from '../../common/components/Button'; import Stream from '../../common/utils/Stream'; -import Icon from '../../common/components/Icon'; import InfoTile from '../../common/components/InfoTile'; import LoadingIndicator from '../../common/components/LoadingIndicator'; @@ -19,6 +18,8 @@ export interface ISearchModalAttrs extends IFormModalAttrs { } export default class SearchModal extends FormModal { + public static LIMIT = 6; + protected searchState!: SearchState; /** @@ -67,9 +68,6 @@ export default class SearchModal
    ; - // Initialize the active source. if (!this.activeSource) this.activeSource = Stream(this.sources[0]); @@ -101,17 +99,7 @@ export default class SearchModal
    {this.sources?.map((source) => ( - ))} @@ -169,6 +157,15 @@ export default class SearchModal) { super.onupdate(vnode); @@ -204,6 +201,8 @@ export default class SearchModal this.setIndex(this.getCurrentNumericIndex() - 1, true)) .onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true)) + .onRight(() => this.switchSource(this.sources![(this.sources!.indexOf(this.activeSource()) + 1) % this.sources!.length])) + .onLeft(() => this.switchSource(this.sources![(this.sources!.indexOf(this.activeSource()) - 1 + this.sources!.length) % this.sources!.length])) .onSelect(this.selectResult.bind(this), true) .onCancel(this.clear.bind(this)) .bindTo($input); @@ -236,7 +235,7 @@ export default class SearchModal { + source.search(query, SearchModal.LIMIT).then(() => { this.loadingSources = this.loadingSources.filter((resource) => resource !== source.resource); m.redraw(); }); @@ -320,7 +319,7 @@ export default class SearchModal .DiscussionListItem { + --discussion-list-item-bg-hover: var(--control-bg); + margin: 0 -16px; + border-radius: 0; + } + + &.active > .DiscussionListItem { + background-color: var(--discussion-list-item-bg-hover); + } } + .UserSearchResult .Avatar { .Avatar--size(24px); margin: -2px 10px -2px 0; @@ -63,16 +70,6 @@ height: 42px; } - &-results { - mark { - background: none; - padding: 0; - font-weight: bold; - color: inherit; - box-shadow: none; - } - } - &-fullPage .LinkButton { font-weight: bold; } diff --git a/framework/core/less/forum/DiscussionListItem.less b/framework/core/less/forum/DiscussionListItem.less index dd10a7e8ff..52da873ac3 100644 --- a/framework/core/less/forum/DiscussionListItem.less +++ b/framework/core/less/forum/DiscussionListItem.less @@ -115,6 +115,47 @@ } } +.MinimalDiscussionListItem { + .DiscussionListItem-info .item-excerpt { + margin-right: 0; + } + + .DiscussionListItem-badges { + position: relative; + padding: 0; + margin: 0; + inset: unset; + width: auto; + text-align: start; + padding-inline-start: 8px; + + &:empty { + display: none; + } + } + + .Badge { + --size: 18px; + box-shadow: none; + margin-left: -8px; + } + + .DiscussionListItem-title { + display: flex; + column-gap: 8px; + align-items: center; + margin-bottom: 6px; + line-height: normal; + white-space: nowrap; + + > div { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } + } +} + @media (any-hover: none) { .DiscussionListItem-controls > .Dropdown-toggle { display: none; From 650b55bdbb864fd784d4a60f786317be4e7fd4d2 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sat, 18 Nov 2023 21:12:47 +0100 Subject: [PATCH 04/19] chore: incorrect code organization --- framework/core/js/src/common/SearchManager.ts | 66 ------------------ framework/core/js/src/common/common.ts | 3 - .../core/js/src/common/extenders/Search.ts | 16 ----- .../forum/components/DiscussionListItem.tsx | 2 +- .../components}/DiscussionsSearchSource.tsx | 10 ++- .../js/src/forum/components/IndexPage.tsx | 2 +- .../core/js/src/forum/components/PostUser.js | 2 +- .../src/forum/components/ReplyPlaceholder.js | 2 +- .../core/js/src/forum/components/Search.tsx | 69 ++++++++++++++++++- .../js/src/forum/components/SearchModal.tsx | 13 ++-- .../components}/UsersSearchSource.tsx | 12 ++-- framework/core/js/src/forum/forum.ts | 2 + framework/core/less/common/Badge.less | 15 ++++ framework/core/less/common/Search.less | 36 +++++++++- .../core/less/forum/DiscussionListItem.less | 12 ---- framework/core/less/forum/Post.less | 6 -- framework/core/locale/core.yml | 2 +- 17 files changed, 139 insertions(+), 131 deletions(-) rename framework/core/js/src/{common/query => forum/components}/DiscussionsSearchSource.tsx (84%) rename framework/core/js/src/{common/query => forum/components}/UsersSearchSource.tsx (83%) diff --git a/framework/core/js/src/common/SearchManager.ts b/framework/core/js/src/common/SearchManager.ts index f1e042b0fd..6b2010015d 100644 --- a/framework/core/js/src/common/SearchManager.ts +++ b/framework/core/js/src/common/SearchManager.ts @@ -1,54 +1,5 @@ import SearchState from './states/SearchState'; -import type Mithril from 'mithril'; import GambitManager from './GambitManager'; -import DiscussionsSearchSource from './query/DiscussionsSearchSource'; -import UsersSearchSource from './query/UsersSearchSource'; -import ItemList from './utils/ItemList'; -import app from '../forum/app'; - -/** - * 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 { - /** - * The resource type that this search source is responsible for. - */ - resource: string; - - /** - * Get the title for this search source. - */ - title(): string; - - /** - * Check if a query has been cached for this search source. - */ - isCached(query: string): boolean; - - /** - * 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, limit: number): Promise; - - /** - * Get an array of virtual
  • s that list the search results for the given - * query. - */ - view(query: string): Array; - - /** - * Get a list item for the full search results page. - */ - fullPage(query: string): Mithril.Vnode | null; -} export default class SearchManager { /** @@ -71,21 +22,4 @@ export default class SearchManager { constructor(state: State) { this.state = state; } - - /** - * A list of search sources that can be used to query for search results. - */ - sources(): ItemList { - const items = new ItemList(); - - if (app.forum.attribute('canViewForum')) { - items.add('discussions', new DiscussionsSearchSource()); - } - - if (app.forum.attribute('canSearchUsers')) { - items.add('users', new UsersSearchSource()); - } - - return items; - } } diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index fbc8713849..5d8792a1dc 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -83,9 +83,6 @@ import './helpers/userOnline'; import './helpers/listItems'; import './helpers/textContrastClass'; -import './query/DiscussionsSearchSource'; -import './query/UsersSearchSource'; - import './resolvers/DefaultResolver'; import './Component'; diff --git a/framework/core/js/src/common/extenders/Search.ts b/framework/core/js/src/common/extenders/Search.ts index 9484e007da..ecee6700be 100644 --- a/framework/core/js/src/common/extenders/Search.ts +++ b/framework/core/js/src/common/extenders/Search.ts @@ -2,12 +2,9 @@ import type IExtender from './IExtender'; import type { IExtensionModule } from './IExtender'; import type Application from '../Application'; import IGambit from '../query/IGambit'; -import SearchManager, { SearchSource } from '../SearchManager'; -import { extend } from '../extend'; export default class Search implements IExtender { protected gambits: Record IGambit>> = {}; - protected sources: Record SearchSource> = {}; public gambit(modelType: string, gambit: new () => IGambit): this { this.gambits[modelType] = this.gambits[modelType] || []; @@ -16,12 +13,6 @@ export default class Search implements IExtender { return this; } - public source(modelType: string, source: new () => SearchSource): this { - this.sources[modelType] = source; - - return this; - } - extend(app: Application, extension: IExtensionModule): void { for (const [modelType, gambits] of Object.entries(this.gambits)) { for (const gambit of gambits) { @@ -29,12 +20,5 @@ export default class Search implements IExtender { app.search.gambits.gambits[modelType].push(gambit); } } - - const sources = this.sources; - extend(SearchManager.prototype, 'sources', function (items) { - for (const [modelType, source] of Object.entries(sources)) { - items.add(modelType, new source()); - } - }); } } diff --git a/framework/core/js/src/forum/components/DiscussionListItem.tsx b/framework/core/js/src/forum/components/DiscussionListItem.tsx index 0fa5a91683..25d9ecc071 100644 --- a/framework/core/js/src/forum/components/DiscussionListItem.tsx +++ b/framework/core/js/src/forum/components/DiscussionListItem.tsx @@ -163,7 +163,7 @@ export default class DiscussionListItem{listItems(discussion.badges().toArray())}; + return
      {listItems(discussion.badges().toArray())}
    ; } mainView(): Mithril.Children { diff --git a/framework/core/js/src/common/query/DiscussionsSearchSource.tsx b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx similarity index 84% rename from framework/core/js/src/common/query/DiscussionsSearchSource.tsx rename to framework/core/js/src/forum/components/DiscussionsSearchSource.tsx index 39397b234c..932b9ee74f 100644 --- a/framework/core/js/src/common/query/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx @@ -1,12 +1,10 @@ -import app from '../../forum/app'; -import highlight from '../../common/helpers/highlight'; +import app from '../app'; import LinkButton from '../../common/components/LinkButton'; -import Link from '../../common/components/Link'; import type Mithril from 'mithril'; import Discussion from '../../common/models/Discussion'; -import type { SearchSource } from '../../common/SearchManager'; -import extractText from '../utils/extractText'; -import MinimalDiscussionListItem from '../../forum/components/MinimalDiscussionListItem'; +import type { SearchSource } from './Search'; +import extractText from '../../common/utils/extractText'; +import MinimalDiscussionListItem from './MinimalDiscussionListItem'; /** * The `DiscussionsSearchSource` finds and displays discussion search results in diff --git a/framework/core/js/src/forum/components/IndexPage.tsx b/framework/core/js/src/forum/components/IndexPage.tsx index d72bf4dce7..ca1fc79613 100644 --- a/framework/core/js/src/forum/components/IndexPage.tsx +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -162,7 +162,7 @@ export default class IndexPage + ); diff --git a/framework/core/js/src/forum/components/PostUser.js b/framework/core/js/src/forum/components/PostUser.js index 3e335fcaa1..8c0f9dd00f 100644 --- a/framework/core/js/src/forum/components/PostUser.js +++ b/framework/core/js/src/forum/components/PostUser.js @@ -38,7 +38,7 @@ export default class PostUser extends Component { {username(user)} -
      {listItems(user.badges().toArray())}
    +
      {listItems(user.badges().toArray())}
    ); } diff --git a/framework/core/js/src/forum/components/ReplyPlaceholder.js b/framework/core/js/src/forum/components/ReplyPlaceholder.js index dd018ce3e9..1f13f4b47b 100644 --- a/framework/core/js/src/forum/components/ReplyPlaceholder.js +++ b/framework/core/js/src/forum/components/ReplyPlaceholder.js @@ -27,7 +27,7 @@ export default class ReplyPlaceholder extends Component {

    {username(app.session.user)}

    -
      {listItems(app.session.user.badges().toArray())}
    +
      {listItems(app.session.user.badges().toArray())}
    diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index ef3cddedb1..83f43b8ada 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -5,12 +5,59 @@ import Input from '../../common/components/Input'; import SearchState from '../../common/states/SearchState'; import SearchModal from './SearchModal'; import type Mithril from 'mithril'; +import ItemList from '../../common/utils/ItemList'; +import DiscussionsSearchSource from './DiscussionsSearchSource'; +import UsersSearchSource from './UsersSearchSource'; 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; } +/** + * 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 { + /** + * The resource type that this search source is responsible for. + */ + resource: string; + + /** + * Get the title for this search source. + */ + title(): string; + + /** + * Check if a query has been cached for this search source. + */ + isCached(query: string): boolean; + + /** + * 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, limit: number): Promise; + + /** + * Get an array of virtual
  • s that list the search results for the given + * query. + */ + view(query: string): Array; + + /** + * Get a list item for the full search results page. + */ + fullPage(query: string): Mithril.Vnode | null; +} + /** * The `Search` component displays a menu of as-you-type results from a variety * of sources. @@ -37,7 +84,7 @@ export default class Search extends Compone view() { // Hide the search view if no sources were loaded - if (app.search.sources().isEmpty()) return
    ; + if (this.sourceItems().isEmpty()) return
    ; const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder')); @@ -56,12 +103,28 @@ export default class Search extends Compone inputAttrs={{ onfocus: () => setTimeout(() => { - this.$('input').blur() && app.modal.show(SearchModal, { searchState: this.searchState }); + this.$('input').blur() && app.modal.show(SearchModal, { searchState: this.searchState, sources: this.sourceItems().toArray() }); }, 150), }} - // onchange={(value: string) => this.searchState.setValue(value)} /> ); } + + /** + * A list of search sources that can be used to query for search results. + */ + sourceItems(): ItemList { + const items = new ItemList(); + + if (app.forum.attribute('canViewForum')) { + items.add('discussions', new DiscussionsSearchSource()); + } + + if (app.forum.attribute('canSearchUsers')) { + items.add('users', new UsersSearchSource()); + } + + return items; + } } diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index fb85db87e8..9ab24916bc 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -4,17 +4,19 @@ import type { IFormModalAttrs } from '../../common/components/FormModal'; import type Mithril from 'mithril'; import type SearchState from '../../common/states/SearchState'; import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable'; -import SearchManager, { SearchSource } from '../../common/SearchManager'; +import SearchManager from '../../common/SearchManager'; import extractText from '../../common/utils/extractText'; import Input from '../../common/components/Input'; import Button from '../../common/components/Button'; import Stream from '../../common/utils/Stream'; import InfoTile from '../../common/components/InfoTile'; import LoadingIndicator from '../../common/components/LoadingIndicator'; +import type { SearchSource } from './Search'; export interface ISearchModalAttrs extends IFormModalAttrs { onchange: (value: string) => void; searchState: SearchState; + sources: SearchSource[]; } export default class SearchModal extends FormModal { @@ -25,7 +27,7 @@ export default class SearchModal - {app.translator.trans('core.forum.search.no_results_text')} + {app.translator.trans('core.forum.search.no_results_text')}
  • )} {loading && ( diff --git a/framework/core/js/src/common/query/UsersSearchSource.tsx b/framework/core/js/src/forum/components/UsersSearchSource.tsx similarity index 83% rename from framework/core/js/src/common/query/UsersSearchSource.tsx rename to framework/core/js/src/forum/components/UsersSearchSource.tsx index adc9c64f63..d70ea6b1ac 100644 --- a/framework/core/js/src/common/query/UsersSearchSource.tsx +++ b/framework/core/js/src/forum/components/UsersSearchSource.tsx @@ -1,13 +1,14 @@ import type Mithril from 'mithril'; -import app from '../../forum/app'; +import app from '../app'; import highlight from '../../common/helpers/highlight'; import username from '../../common/helpers/username'; import Link from '../../common/components/Link'; import User from '../../common/models/User'; import Avatar from '../../common/components/Avatar'; -import type { SearchSource } from '../SearchManager'; -import extractText from '../utils/extractText'; +import type { SearchSource } from './Search'; +import extractText from '../../common/utils/extractText'; +import listItems from '../../common/helpers/listItems'; /** * The `UsersSearchSource` finds and displays user search results in the search @@ -59,7 +60,10 @@ export default class UsersSearchResults implements SearchSource {
  • - {name} +
    + {name} +
    {listItems(user.badges().toArray())}
    +
  • ); diff --git a/framework/core/js/src/forum/forum.ts b/framework/core/js/src/forum/forum.ts index 9d10c501fb..09fc502868 100644 --- a/framework/core/js/src/forum/forum.ts +++ b/framework/core/js/src/forum/forum.ts @@ -22,6 +22,7 @@ import './components/HeaderPrimary'; import './components/PostEdited'; import './components/IndexPage'; import './components/DiscussionRenamedNotification'; +import './components/DiscussionsSearchSource'; import './components/HeaderSecondary'; import './components/DiscussionList'; import './components/AvatarEditor'; @@ -31,6 +32,7 @@ import './components/NotificationsDropdown'; import './components/UserPage'; import './components/PostUser'; import './components/UserCard'; +import './components/UsersSearchSource'; import './components/PostPreview'; import './components/EventPost'; import './components/DiscussionHero'; diff --git a/framework/core/less/common/Badge.less b/framework/core/less/common/Badge.less index 29e62d0c8c..a1f94183c0 100644 --- a/framework/core/less/common/Badge.less +++ b/framework/core/less/common/Badge.less @@ -31,6 +31,21 @@ &, > li { display: inline-block; } + + &--packed { + --packing-space: 10px; + padding-inline-start: var(--packing-space); + + .Badge { + margin-left: calc(~"0px - var(--packing-space)"); + position: relative; + pointer-events: auto; + } + } + + &:empty { + display: none; + } } .Badge--hidden { diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index a85ac15dbb..8a9d88f0b8 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -14,9 +14,23 @@ } } -.UserSearchResult .Avatar { - .Avatar--size(24px); - margin: -2px 10px -2px 0; +.UserSearchResult { + margin: 0 -16px; + + .badges { + margin-inline-start: 4px; + } + > a { + gap: 6px; + padding: 12px 15px; + border-radius: 0; + } + .Avatar { + --size: 36px; + } + .username { + font-size: 15px; + } } .SearchModal { @@ -70,6 +84,22 @@ height: 42px; } + &-results { + mark { + background: none; + padding: 0; + font-weight: bold; + color: inherit; + box-shadow: none; + } + + .Badge { + --packing-space: 8px; + --size: 18px; + box-shadow: none; + } + } + &-fullPage .LinkButton { font-weight: bold; } diff --git a/framework/core/less/forum/DiscussionListItem.less b/framework/core/less/forum/DiscussionListItem.less index 52da873ac3..c04312591c 100644 --- a/framework/core/less/forum/DiscussionListItem.less +++ b/framework/core/less/forum/DiscussionListItem.less @@ -37,12 +37,6 @@ position: absolute; top: 0; left: -2px; - - .Badge { - margin-left: -10px; - position: relative; - pointer-events: auto; - } } .DiscussionListItem-title { margin: 0 0 3px; @@ -134,12 +128,6 @@ } } - .Badge { - --size: 18px; - box-shadow: none; - margin-left: -8px; - } - .DiscussionListItem-title { display: flex; column-gap: 8px; diff --git a/framework/core/less/forum/Post.less b/framework/core/less/forum/Post.less index 264a1f3a98..21167b5b45 100644 --- a/framework/core/less/forum/Post.less +++ b/framework/core/less/forum/Post.less @@ -84,12 +84,6 @@ text-align: right; white-space: nowrap; pointer-events: none; - - .Badge { - margin-left: -10px; - position: relative; - pointer-events: auto; - } } .Post-body { diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 0f105e1b44..92a10fb9cc 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -522,7 +522,7 @@ core: # These translations are used by the search modal. search: title: Search - no_results_text: No results found. + no_results_text: It looks like there are no results here. no_search_text: You have not searched for anything yet. options_heading: Search options placeholder: Search... From b15b14b38fd5d20bb303529002f828c090ba2283 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 24 Nov 2023 13:34:41 +0100 Subject: [PATCH 05/19] feat: gambit input suggestions --- .../js/src/forum/addComposerAutocomplete.js | 42 ++---- .../common/query/discussions/LockedGambit.ts | 14 +- extensions/lock/locale/en.yml | 9 ++ .../js/src/forum/addComposerAutocomplete.js | 55 +++----- .../forum/mentionables/MentionableModels.tsx | 5 +- .../common/query/discussions/StickyGambit.ts | 14 +- extensions/sticky/locale/en.yml | 9 ++ .../query/discussions/SubscriptionGambit.ts | 17 ++- extensions/subscriptions/locale/en.yml | 10 ++ .../src/common/query/users/SuspendedGambit.ts | 14 +- extensions/suspend/locale/en.yml | 9 ++ .../src/common/query/discussions/TagGambit.ts | 14 +- extensions/tags/locale/en.yml | 7 + .../js/src/@types/translator-icu-rich.d.ts | 2 +- .../js/src/admin/components/UserListPage.tsx | 1 + framework/core/js/src/common/GambitManager.ts | 17 +-- framework/core/js/src/common/Translator.tsx | 15 ++- framework/core/js/src/common/common.ts | 3 + .../core/js/src/common/components/Input.tsx | 11 +- .../core/js/src/common/extenders/Search.ts | 4 +- framework/core/js/src/common/query/IGambit.ts | 19 ++- .../common/query/discussions/AuthorGambit.ts | 14 +- .../common/query/discussions/CreatedGambit.ts | 14 +- .../common/query/discussions/HiddenGambit.ts | 14 +- .../common/query/discussions/UnreadGambit.ts | 14 +- .../js/src/common/query/users/EmailGambit.ts | 14 +- .../js/src/common/query/users/GroupGambit.ts | 14 +- .../js/src/common/utils/AutocompleteReader.ts | 51 +++++++ .../js/src/forum/components/SearchModal.tsx | 124 +++++++++++++++++- framework/core/less/common/Search.less | 11 ++ framework/core/locale/core.yml | 22 ++++ .../webpack-config/src/autoExportLoader.cjs | 4 +- 32 files changed, 469 insertions(+), 118 deletions(-) create mode 100644 framework/core/js/src/common/utils/AutocompleteReader.ts diff --git a/extensions/emoji/js/src/forum/addComposerAutocomplete.js b/extensions/emoji/js/src/forum/addComposerAutocomplete.js index 30a8626850..a0e43e415c 100644 --- a/extensions/emoji/js/src/forum/addComposerAutocomplete.js +++ b/extensions/emoji/js/src/forum/addComposerAutocomplete.js @@ -2,6 +2,7 @@ import { extend } from 'flarum/common/extend'; import TextEditorButton from 'flarum/common/components/TextEditorButton'; import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; import Tooltip from 'flarum/common/components/Tooltip'; +import AutocompleteReader from 'flarum/common/utils/AutocompleteReader'; import AutocompleteDropdown from './fragments/AutocompleteDropdown'; import getEmojiIconCode from './helpers/getEmojiIconCode'; @@ -40,15 +41,7 @@ export default function addComposerAutocomplete() { extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) { const emojiKeys = Object.keys(emojiMap); - let relEmojiStart; - let absEmojiStart; - let typed; - - const applySuggestion = (replacement) => { - this.attrs.composer.editor.replaceBeforeCursor(absEmojiStart - 1, replacement + ' '); - - this.emojiDropdown.hide(); - }; + const autocompleteReader = new AutocompleteReader(':'); params.inputListeners.push(() => { const selection = this.attrs.composer.editor.getSelectionRange(); @@ -57,29 +50,20 @@ export default function addComposerAutocomplete() { if (selection[1] - cursor > 0) return; - // Search backwards from the cursor for an ':' symbol. If we find - // one and followed by a whitespace, we will want to show the - // autocomplete dropdown! const lastChunk = this.attrs.composer.editor.getLastNChars(15); - absEmojiStart = 0; - for (let i = lastChunk.length - 1; i >= 0; i--) { - const character = lastChunk.substr(i, 1); - // check what user typed, emoji names only contains alphanumeric, - // underline, '+' and '-' - if (!/[a-z0-9]|\+|\-|_|\:/.test(character)) break; - // make sure ':' preceded by a whitespace or newline - if (character === ':' && (i == 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) { - relEmojiStart = i + 1; - absEmojiStart = cursor - lastChunk.length + i + 1; - break; - } - } + const autocompleting = autocompleteReader.check(lastChunk, cursor, /[a-z0-9]|\+|\-|_|\:/); this.emojiDropdown.hide(); this.emojiDropdown.active = false; - if (absEmojiStart) { - typed = lastChunk.substring(relEmojiStart).toLowerCase(); + if (autocompleting) { + const typed = autocompleting.typed; + const emojiDropdown = this.emojiDropdown; + + const applySuggestion = (replacement) => { + this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' '); + this.emojiDropdown.hide(); + }; const makeSuggestion = function ({ emoji, name, code }) { return ( @@ -88,7 +72,7 @@ export default function addComposerAutocomplete() { key={emoji} onclick={() => applySuggestion(emoji)} onmouseenter={function () { - this.emojiDropdown.setIndex($(this).parent().index() - 1); + emojiDropdown.setIndex($(this).parent().index() - 1); }} > {emoji} @@ -152,7 +136,7 @@ export default function addComposerAutocomplete() { m.render(this.$('.ComposerBody-emojiDropdownContainer')[0], this.emojiDropdown.render()); this.emojiDropdown.show(); - const coordinates = this.attrs.composer.editor.getCaretCoordinates(absEmojiStart); + const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart); const width = this.emojiDropdown.$().outerWidth(); const height = this.emojiDropdown.$().outerHeight(); const parent = this.emojiDropdown.$().offsetParent(); diff --git a/extensions/lock/js/src/common/query/discussions/LockedGambit.ts b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts index b3639fd35d..11c1f93690 100644 --- a/extensions/lock/js/src/common/query/discussions/LockedGambit.ts +++ b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts @@ -1,6 +1,9 @@ -import IGambit from 'flarum/common/query/IGambit'; +import IGambit, { GambitType } from 'flarum/common/query/IGambit'; +import app from 'flarum/common/app'; + +export default class LockedGambit implements IGambit { + type = GambitType.Grouped; -export default class LockedGambit implements IGambit { pattern(): string { return 'is:locked'; } @@ -20,4 +23,11 @@ export default class LockedGambit implements IGambit { fromFilter(value: string, negate: boolean): string { return `${negate ? '-' : ''}is:locked`; } + + suggestion() { + return { + group: 'is', + key: app.translator.trans('flarum-lock.lib.gambits.discussions.locked.key', {}, true), + }; + } } diff --git a/extensions/lock/locale/en.yml b/extensions/lock/locale/en.yml index ed13f3bb84..5690fd0b0d 100644 --- a/extensions/lock/locale/en.yml +++ b/extensions/lock/locale/en.yml @@ -35,3 +35,12 @@ flarum-lock: # These translations are used in the Settings page. settings: notify_discussion_locked_label: Someone locks a discussion I started + + # Translations in this namespace are used by the forum and admin interfaces. + lib: + + # These translations are used by gambits. Gambit keys must be in snake_case, no spaces. + gambits: + discussions: + locked: + key: locked diff --git a/extensions/mentions/js/src/forum/addComposerAutocomplete.js b/extensions/mentions/js/src/forum/addComposerAutocomplete.js index b7344c1f49..ddcc7a2a0c 100644 --- a/extensions/mentions/js/src/forum/addComposerAutocomplete.js +++ b/extensions/mentions/js/src/forum/addComposerAutocomplete.js @@ -2,6 +2,8 @@ import app from 'flarum/forum/app'; import { extend } from 'flarum/common/extend'; import TextEditorButton from 'flarum/common/components/TextEditorButton'; import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; +import AutocompleteReader from 'flarum/common/utils/AutocompleteReader'; +import { throttle } from 'flarum/common/utils/throttleDebounce'; import AutocompleteDropdown from './fragments/AutocompleteDropdown'; import MentionableModels from './mentionables/MentionableModels'; @@ -9,6 +11,7 @@ import MentionableModels from './mentionables/MentionableModels'; export default function addComposerAutocomplete() { extend('flarum/common/components/TextEditor', 'onbuild', function () { this.mentionsDropdown = new AutocompleteDropdown(); + this.searchMentions = throttle(250, (mentionables, buildSuggestions) => mentionables.search().then(buildSuggestions)); const $editor = this.$('.TextEditor-editor').wrap('
    '); this.navigator = new KeyboardNavigatable(); @@ -24,21 +27,8 @@ export default function addComposerAutocomplete() { }); extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) { - let relMentionStart; - let absMentionStart; let matchTyped; - let mentionables = new MentionableModels({ - onmouseenter: function () { - this.mentionsDropdown.setIndex($(this).parent().index()); - }, - onclick: (replacement) => { - this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' '); - - this.mentionsDropdown.hide(); - }, - }); - const suggestionsInputListener = () => { const selection = this.attrs.composer.editor.getSelectionRange(); @@ -46,30 +36,27 @@ export default function addComposerAutocomplete() { if (selection[1] - cursor > 0) return; - // Search backwards from the cursor for a mention triggering symbol. If we find one, - // we will want to show the correct autocomplete dropdown! - // Check classes implementing the IMentionableModel interface to see triggering symbols. - const lastChunk = this.attrs.composer.editor.getLastNChars(30); - absMentionStart = 0; let activeFormat = null; - for (let i = lastChunk.length - 1; i >= 0; i--) { - const character = lastChunk.substr(i, 1); - activeFormat = app.mentionFormats.get(character); - - if (activeFormat && (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) { - relMentionStart = i + 1; - absMentionStart = cursor - lastChunk.length + i + 1; - mentionables.init(activeFormat.makeMentionables()); - break; - } - } + const autocompleteReader = new AutocompleteReader((character) => !!(activeFormat = app.mentionFormats.get(character))); + const autocompleting = autocompleteReader.check(this.attrs.composer.editor.getLastNChars(30), cursor, /\S+/); + + const mentionsDropdown = this.mentionsDropdown; + let mentionables = new MentionableModels({ + onmouseenter: function () { + mentionsDropdown.setIndex($(this).parent().index()); + }, + onclick: (replacement) => { + this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' '); + this.mentionsDropdown.hide(); + }, + }); this.mentionsDropdown.hide(); this.mentionsDropdown.active = false; - if (absMentionStart) { - const typed = lastChunk.substring(relMentionStart).toLowerCase(); - matchTyped = activeFormat.queryFromTyped(typed); + if (autocompleting) { + mentionables.init(activeFormat.makeMentionables()); + matchTyped = activeFormat.queryFromTyped(autocompleting.typed); if (!matchTyped) return; @@ -85,7 +72,7 @@ export default function addComposerAutocomplete() { m.render(this.$('.ComposerBody-mentionsDropdownContainer')[0], this.mentionsDropdown.render()); this.mentionsDropdown.show(); - const coordinates = this.attrs.composer.editor.getCaretCoordinates(absMentionStart); + const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart); const width = this.mentionsDropdown.$().outerWidth(); const height = this.mentionsDropdown.$().outerHeight(); const parent = this.mentionsDropdown.$().offsetParent(); @@ -118,7 +105,7 @@ export default function addComposerAutocomplete() { this.mentionsDropdown.setIndex(0); this.mentionsDropdown.$().scrollTop(0); - mentionables.search()?.then(buildSuggestions); + this.searchMentions(mentionables, buildSuggestions); } }; diff --git a/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx b/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx index 4a4435ef23..a738476dec 100644 --- a/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx +++ b/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx @@ -2,7 +2,6 @@ import type MentionableModel from './MentionableModel'; import type Model from 'flarum/common/Model'; import type Mithril from 'mithril'; import MentionsDropdownItem from '../components/MentionsDropdownItem'; -import { throttle } from 'flarum/common/utils/throttleDebounce'; export default class MentionableModels { protected mentionables?: MentionableModel[]; @@ -33,7 +32,7 @@ export default class MentionableModels { * Don't send API calls searching for models until at least 2 characters have been typed. * This focuses the mention results on models already loaded. */ - public readonly search = throttle(250, async (): Promise => { + public readonly search = async (): Promise => { if (!this.typed || this.typed.length <= 1) return; const typedLower = this.typed.toLowerCase(); @@ -51,7 +50,7 @@ export default class MentionableModels { this.searched.push(typedLower); return Promise.resolve(); - }); + }; public matches(mentionable: MentionableModel, model: Model): boolean { return mentionable.matches(model, this.typed?.toLowerCase() || ''); diff --git a/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts index d1ab9f688c..e906542031 100644 --- a/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts +++ b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts @@ -1,6 +1,9 @@ -import IGambit from 'flarum/common/query/IGambit'; +import IGambit, { GambitType } from 'flarum/common/query/IGambit'; +import app from 'flarum/common/app'; + +export default class StickyGambit implements IGambit { + type = GambitType.Grouped; -export default class StickyGambit implements IGambit { pattern(): string { return 'is:sticky'; } @@ -20,4 +23,11 @@ export default class StickyGambit implements IGambit { fromFilter(value: string, negate: boolean): string { return `${negate ? '-' : ''}is:sticky`; } + + suggestion() { + return { + group: 'is', + key: app.translator.trans('flarum-sticky.lib.gambits.discussions.sticky.key', {}, true), + }; + } } diff --git a/extensions/sticky/locale/en.yml b/extensions/sticky/locale/en.yml index 414c04a888..64407a52f7 100644 --- a/extensions/sticky/locale/en.yml +++ b/extensions/sticky/locale/en.yml @@ -32,6 +32,15 @@ flarum-sticky: # REUSED TRANSLATIONS - These keys should not be used directly in code! ## + # Translations in this namespace are used by the forum and admin interfaces. + lib: + + # These translations are used by gambits. Gambit keys must be in snake_case, no spaces. + gambits: + discussions: + sticky: + key: sticky + # Translations in this namespace are referenced by two or more unique keys. ref: sticky: Sticky diff --git a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts index 6d1a137078..8ff560f012 100644 --- a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts +++ b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts @@ -1,6 +1,9 @@ -import IGambit from 'flarum/common/query/IGambit'; +import app from 'flarum/common/app'; +import IGambit, { GambitType } from 'flarum/common/query/IGambit'; + +export default class SubscriptionGambit implements IGambit { + type = GambitType.Grouped; -export default class SubscriptionGambit implements IGambit { pattern(): string { return 'is:(follow|ignor)(?:ing|ed)'; } @@ -20,4 +23,14 @@ export default class SubscriptionGambit implements IGambit { fromFilter(value: string, negate: boolean): string { return `${negate ? '-' : ''}is:${value}`; } + + suggestion() { + return { + group: 'is', + key: [ + app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.following_key', {}, true), + app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.ignoring_key', {}, true), + ], + }; + } } diff --git a/extensions/subscriptions/locale/en.yml b/extensions/subscriptions/locale/en.yml index 135e30e970..a6b507d361 100644 --- a/extensions/subscriptions/locale/en.yml +++ b/extensions/subscriptions/locale/en.yml @@ -75,6 +75,16 @@ flarum-subscriptions: # REUSED TRANSLATIONS - These keys should not be used directly in code! ## + # Translations in this namespace are used by the forum and admin interfaces. + lib: + + # These translations are used by gambits. Gambit keys must be in snake_case, no spaces. + gambits: + discussions: + subscription: + following_key: following + ignoring_key: ignoring + # Translations in this namespace are referenced by two or more unique keys. ref: follow: Follow diff --git a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts index f8c2c4c6c0..3c3bae42e9 100644 --- a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts +++ b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts @@ -1,6 +1,9 @@ -import IGambit from 'flarum/common/query/IGambit'; +import app from 'flarum/common/app'; +import IGambit, { GambitType } from 'flarum/common/query/IGambit'; + +export default class SuspendedGambit implements IGambit { + type = GambitType.Grouped; -export default class SuspendedGambit implements IGambit { pattern(): string { return 'is:suspended'; } @@ -20,4 +23,11 @@ export default class SuspendedGambit implements IGambit { fromFilter(value: string, negate: boolean): string { return `${negate ? '-' : ''}is:suspended`; } + + suggestion() { + return { + group: 'is', + key: app.translator.trans('flarum-suspend.lib.gambits.users.suspended.key', {}, true), + }; + } } diff --git a/extensions/suspend/locale/en.yml b/extensions/suspend/locale/en.yml index 0e4d81d538..900ea57531 100644 --- a/extensions/suspend/locale/en.yml +++ b/extensions/suspend/locale/en.yml @@ -71,3 +71,12 @@ flarum-suspend: {forum_url} html: body: "You have been unsuspended. You can head back to [{forumTitle}]({forum_url}) when you are ready." + + # Translations in this namespace are used by the forum and admin interfaces. + lib: + + # These translations are used by gambits. Gambit keys must be in snake_case, no spaces. + gambits: + users: + suspended: + key: suspended diff --git a/extensions/tags/js/src/common/query/discussions/TagGambit.ts b/extensions/tags/js/src/common/query/discussions/TagGambit.ts index 0cae0b17bc..886fb07676 100644 --- a/extensions/tags/js/src/common/query/discussions/TagGambit.ts +++ b/extensions/tags/js/src/common/query/discussions/TagGambit.ts @@ -1,6 +1,9 @@ -import IGambit from 'flarum/common/query/IGambit'; +import app from 'flarum/common/app'; +import IGambit, { GambitType } from 'flarum/common/query/IGambit'; + +export default class TagGambit implements IGambit { + type = GambitType.KeyValue; -export default class TagGambit implements IGambit { pattern(): string { return 'tag:(.+)'; } @@ -20,4 +23,11 @@ export default class TagGambit implements IGambit { fromFilter(value: string, negate: boolean): string { return `${negate ? '-' : ''}tag:${value}`; } + + suggestion() { + return { + key: app.translator.trans('flarum-tags.lib.gambits.discussions.tag.key', {}, true), + hint: app.translator.trans('flarum-tags.lib.gambits.discussions.tag.hint', {}, true), + }; + } } diff --git a/extensions/tags/locale/en.yml b/extensions/tags/locale/en.yml index 3890539d2d..fb85e4b294 100644 --- a/extensions/tags/locale/en.yml +++ b/extensions/tags/locale/en.yml @@ -107,6 +107,13 @@ flarum-tags: # This translation is displayed in place of the name of a tag that's been deleted. deleted_tag_text: Deleted + # These translations are used by gambits. Gambit keys must be in snake_case, no spaces. + gambits: + discussions: + tag: + key: tag + hint: name of the tag + # These translations are used in the tag selection modal. tag_selection_modal: bypass_requirements: Bypass tag requirements diff --git a/framework/core/js/src/@types/translator-icu-rich.d.ts b/framework/core/js/src/@types/translator-icu-rich.d.ts index 08fb527651..dbecd881ca 100644 --- a/framework/core/js/src/@types/translator-icu-rich.d.ts +++ b/framework/core/js/src/@types/translator-icu-rich.d.ts @@ -11,7 +11,7 @@ declare module '@askvortsov/rich-icu-message-formatter' { type IRichHandler = (tag: any, values: IValues, contents: string) => any; type ValueOrArray = T | ValueOrArray[]; - type NestedStringArray = ValueOrArray; + export type NestedStringArray = ValueOrArray; export class RichMessageFormatter { locale: string | null; diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index 14e5cd615e..6d1c1a0258 100644 --- a/framework/core/js/src/admin/components/UserListPage.tsx +++ b/framework/core/js/src/admin/components/UserListPage.tsx @@ -242,6 +242,7 @@ export default class UserListPage extends AdminPage { placeholder={app.translator.trans('core.admin.users.search_placeholder')} clearable={true} loading={this.isLoadingPage} + value={this.query} onchange={(value: string) => { this.isLoadingPage = true; this.query = value; diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts index 10774c0920..39eb8267a8 100644 --- a/framework/core/js/src/common/GambitManager.ts +++ b/framework/core/js/src/common/GambitManager.ts @@ -1,4 +1,4 @@ -import IGambit from './query/IGambit'; +import type IGambit from './query/IGambit'; import AuthorGambit from './query/discussions/AuthorGambit'; import CreatedGambit from './query/discussions/CreatedGambit'; import HiddenGambit from './query/discussions/HiddenGambit'; @@ -19,15 +19,13 @@ export default class GambitManager { }; public apply(type: string, filter: Record): Record { - const gambits = this.gambits[type] || []; + const gambits = this.for(type); if (gambits.length === 0) return filter; const bits: string[] = filter.q.split(' '); - for (const gambitClass of gambits) { - const gambit = new gambitClass(); - + for (const gambit of gambits) { for (const bit of bits) { const pattern = `^(-?)${gambit.pattern()}$`; let matches = bit.match(pattern); @@ -50,13 +48,12 @@ export default class GambitManager { } public from(type: string, q: string, filter: Record): string { - const gambits = this.gambits[type] || []; + const gambits = this.for(type); if (gambits.length === 0) return q; Object.keys(filter).forEach((key) => { - for (const gambitClass of gambits) { - const gambit = new gambitClass(); + for (const gambit of gambits) { const negate = key[0] === '-'; if (negate) key = key.substring(1); @@ -69,4 +66,8 @@ export default class GambitManager { return q; } + + for(type: string): Array { + return (this.gambits[type] || []).map((gambitClass) => new gambitClass()); + } } diff --git a/framework/core/js/src/common/Translator.tsx b/framework/core/js/src/common/Translator.tsx index 3ac1896def..29498fb3a5 100644 --- a/framework/core/js/src/common/Translator.tsx +++ b/framework/core/js/src/common/Translator.tsx @@ -1,8 +1,9 @@ -import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter'; +import { RichMessageFormatter, mithrilRichHandler, NestedStringArray } from '@askvortsov/rich-icu-message-formatter'; import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter'; import username from './helpers/username'; import User from './models/User'; import extract from './utils/extract'; +import extractText from './utils/extractText'; type Translations = Record; type TranslatorParameters = Record; @@ -69,12 +70,20 @@ export default class Translator { return parameters; } - trans(id: string, parameters: TranslatorParameters = {}) { + trans(id: string, parameters: TranslatorParameters): NestedStringArray; + trans(id: string, parameters: TranslatorParameters, extract: false): NestedStringArray; + trans(id: string, parameters: TranslatorParameters, extract: true): string; + trans(id: string): NestedStringArray | string; + trans(id: string, parameters: TranslatorParameters = {}, extract = false) { const translation = this.translations[id]; if (translation) { parameters = this.preprocessParameters(parameters); - return this.formatter.rich(translation, parameters); + const locale = this.formatter.rich(translation, parameters); + + if (extract) return extractText(locale); + + return locale; } return id; diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index 5d8792a1dc..cec88e450f 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -7,6 +7,7 @@ import './states/ModalManagerState'; import './states/PageState'; import './states/SearchState'; +import './utils/AutocompleteReader'; import './utils/isObject'; import './utils/mixin'; import './utils/insertText'; @@ -83,6 +84,8 @@ import './helpers/userOnline'; import './helpers/listItems'; import './helpers/textContrastClass'; +import './query/IGambit'; + import './resolvers/DefaultResolver'; import './Component'; diff --git a/framework/core/js/src/common/components/Input.tsx b/framework/core/js/src/common/components/Input.tsx index 5c5f75d4ec..0daf541e8e 100644 --- a/framework/core/js/src/common/components/Input.tsx +++ b/framework/core/js/src/common/components/Input.tsx @@ -30,16 +30,15 @@ export interface IInputAttrs extends ComponentAttrs { } export default class Input extends Component { - protected value!: Stream; - oninit(vnode: Mithril.Vnode) { super.oninit(vnode); - this.value = Stream(this.attrs.value || this.attrs.stream?.() || ''); } view(vnode: Mithril.Vnode): Mithril.Children { const { className: inputClassName, ...inputAttrs } = this.attrs.inputAttrs || {}; + const value = this.attrs.value || this.attrs.stream?.() || ''; + return (
    extend this.onchange?.((e.target as HTMLInputElement).value)} aria-label={this.attrs.ariaLabel} placeholder={this.attrs.placeholder} @@ -60,7 +59,7 @@ export default class Input extend {...inputAttrs} /> {this.attrs.loading && } - {this.attrs.clearable && this.value() && !this.attrs.loading && ( + {this.attrs.clearable && value && !this.attrs.loading && ( + + ); + } + + suggest(text: string, fromTyped: string, start: number) { + const $input = this.$('input') as JQuery; + + const query = this.searchState.getValue(); + const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length); + + this.searchState.setValue(replaced); + $input[0].focus(); + setTimeout(() => { + $input[0].setSelectionRange(start + text.length, start + text.length); + m.redraw(); + }, 50); + } + onupdate(vnode: Mithril.VnodeDOM) { super.onupdate(vnode); @@ -200,8 +314,6 @@ export default class SearchModal this.setIndex(this.getCurrentNumericIndex() - 1, true)) .onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true)) - .onRight(() => this.switchSource(this.sources![(this.sources!.indexOf(this.activeSource()) + 1) % this.sources!.length])) - .onLeft(() => this.switchSource(this.sources![(this.sources!.indexOf(this.activeSource()) - 1 + this.sources!.length) % this.sources!.length])) .onSelect(this.selectResult.bind(this), true) .onCancel(this.clear.bind(this)) .bindTo($input); diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index 8a9d88f0b8..6fab3618f9 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -107,4 +107,15 @@ .Dropdown-menu>li>a, .Dropdown-menu>li>button, .Dropdown-menu>li>span { border-radius: var(--border-radius); } + + &-gambit { + gap: 4px !important; + + &-key { + font-weight: bold; + } + &-value { + color: var(--control-color); + } + } } diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 92a10fb9cc..db359157ee 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -704,6 +704,28 @@ core: users: heading: => core.ref.users + # These translations are used by gambits. Gambit keys must be in snake_case, no spaces. + gambits: + boolean_key: is + discussions: + author: + key: author + hint: username + created: + key: created + hint: 2020-12-31 or 2020-12-31..2021-09-30 + hidden: + key: hidden + unread: + key: unread + users: + email: + key: email + hint: example@machine.local + group: + key: group + hint: singular or plural group name + # These translations are used to punctuate a series of items. series: glue_text: ", " diff --git a/js-packages/webpack-config/src/autoExportLoader.cjs b/js-packages/webpack-config/src/autoExportLoader.cjs index 0a8b3bab37..40bab278a9 100644 --- a/js-packages/webpack-config/src/autoExportLoader.cjs +++ b/js-packages/webpack-config/src/autoExportLoader.cjs @@ -91,10 +91,10 @@ function addAutoExports(source, pathToModule, moduleName) { } // 2.3. Finally, we check for all named exports - // these can be `export function|class|.. Name ..` + // these can be `export function|class|enum|.. Name ..` // or `export { ... }; { - const matches = [...source.matchAll(/export\s+?(?:\* as|function|{\s*([A-z0-9, ]+)+\s?}|const|abstract\s?|class)+?\s?([A-Za-z_]*)?/gm)]; + const matches = [...source.matchAll(/export\s+?(?:\* as|function|{\s*([A-z0-9, ]+)+\s?}|const|let|abstract\s?|class)+?\s?([A-Za-z_]*)?/gm)]; if (matches.length) { const map = matches.reduce((map, match) => { From 88108bfe2aed72c38debc458c3b6b15c6898308f Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 24 Nov 2023 13:43:55 +0100 Subject: [PATCH 06/19] feat: gambit keyboard navigation --- .../js/src/forum/components/SearchModal.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index d8ae4d73ab..27863c9bec 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -365,14 +365,19 @@ export default class SearchModal Date: Fri, 24 Nov 2023 14:03:26 +0100 Subject: [PATCH 07/19] chore: bugs --- .../core/js/src/forum/components/DiscussionsSearchSource.tsx | 2 +- framework/core/js/src/forum/components/Search.tsx | 4 ++++ framework/core/js/src/forum/components/SearchModal.tsx | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx index 932b9ee74f..ad0f77c7af 100644 --- a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx @@ -31,7 +31,7 @@ export default class DiscussionsSearchSource implements SearchSource { const params = { filter: { q: query }, page: { limit }, - include: 'mostRelevantPost', + include: 'mostRelevantPost,user,firstPost', }; return app.store.find('discussions', params).then((results) => { diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index 83f43b8ada..82014c3e4d 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -100,6 +100,10 @@ export default class Search extends Compone readonly={true} placeholder={searchLabel} value={this.searchState.getValue()} + onchange={(value: string) => { + if (!value) this.searchState.clear(); + else this.searchState.setValue(value); + }} inputAttrs={{ onfocus: () => setTimeout(() => { diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index 27863c9bec..f8adcabc83 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -277,6 +277,8 @@ export default class SearchModal) { @@ -369,7 +371,8 @@ export default class SearchModal Date: Fri, 24 Nov 2023 14:56:26 +0100 Subject: [PATCH 08/19] feat: negative gambits --- .../js/src/forum/components/SearchModal.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index f8adcabc83..75effa9cd4 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -214,7 +214,13 @@ export default class SearchModal !groupQuery || key.toLowerCase().startsWith(groupQuery)) - .map((gambit) => this.gambitSuggestions(gambit, null, () => this.suggest(gambit, groupQuery, autocomplete!.relativeStart + typed.length))); + .map((gambit) => + this.gambitSuggestions(gambit, null, () => this.suggest(gambit, groupQuery, autocomplete!.relativeStart + autocomplete!.typed.length)) + ); } } @@ -237,7 +245,7 @@ export default class SearchModal !autocomplete || - new RegExp(autocomplete.typed).test( + new RegExp(typed).test( gambit.type === GambitType.Grouped ? (gambit.suggestion() as GroupedGambitSuggestion).group : (gambit.suggestion().key as string) ) ) @@ -247,7 +255,9 @@ export default class SearchModal this.suggest(key + ':', typed || '', autocomplete?.relativeStart ?? cursorPosition)); + return this.gambitSuggestions(key, hint, () => + this.suggest(key + ':', typed || '', (autocomplete?.relativeStart ?? cursorPosition) + Number(negative)) + ); }); } @@ -321,16 +331,9 @@ export default class SearchModal e.preventDefault()) - .trigger('select'); - }); + $input.on('input focus', function () { + search(this.value.toLowerCase()); + }); } search(query: string) { From ac396c0ad1629dee7ce4aca8d75fa4910cb4e953 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Wed, 29 Nov 2023 17:17:38 +0100 Subject: [PATCH 09/19] feat: improve gambit highlighting --- framework/core/js/src/common/GambitManager.ts | 20 ++++-- .../core/js/src/common/components/Input.tsx | 33 ++++++--- .../components/DiscussionsSearchSource.tsx | 12 +++- .../core/js/src/forum/components/Search.tsx | 5 ++ .../js/src/forum/components/SearchModal.tsx | 67 +++++++++++++++---- .../forum/components/UsersSearchSource.tsx | 12 +++- framework/core/less/common/Search.less | 37 ++++++++++ 7 files changed, 153 insertions(+), 33 deletions(-) diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts index 39eb8267a8..9bd055f27c 100644 --- a/framework/core/js/src/common/GambitManager.ts +++ b/framework/core/js/src/common/GambitManager.ts @@ -19,11 +19,19 @@ export default class GambitManager { }; public apply(type: string, filter: Record): Record { + filter.q = this.match(type, filter.q, (gambit, matches, negate) => { + Object.assign(filter, gambit.toFilter(matches, negate)); + }); + + return filter; + } + + public match(type: string, query: string, onmatch: (gambit: IGambit, matches: string[], negate: boolean, bit: string) => void): string { const gambits = this.for(type); - if (gambits.length === 0) return filter; + if (gambits.length === 0) return query; - const bits: string[] = filter.q.split(' '); + const bits: string[] = query.split(' '); for (const gambit of gambits) { for (const bit of bits) { @@ -35,16 +43,16 @@ export default class GambitManager { matches.splice(1, 1); - Object.assign(filter, gambit.toFilter(matches, negate)); + onmatch(gambit, matches, negate, bit); - filter.q = filter.q.replace(bit, ''); + query = query.replace(bit, ''); } } } - filter.q = filter.q.trim().replace(/\s+/g, ' '); + query = query.trim().replace(/\s+/g, ' '); - return filter; + return query; } public from(type: string, q: string, filter: Record): string { diff --git a/framework/core/js/src/common/components/Input.tsx b/framework/core/js/src/common/components/Input.tsx index 0daf541e8e..886a54cf25 100644 --- a/framework/core/js/src/common/components/Input.tsx +++ b/framework/core/js/src/common/components/Input.tsx @@ -23,6 +23,7 @@ export interface IInputAttrs extends ComponentAttrs { placeholder?: string; readonly?: boolean; disabled?: boolean; + renderInput?: (attrs: any) => Mithril.Children; inputAttrs?: { className?: string; [key: string]: any; @@ -47,17 +48,7 @@ export default class Input extend })} > {this.attrs.prefixIcon && } - this.onchange?.((e.target as HTMLInputElement).value)} - aria-label={this.attrs.ariaLabel} - placeholder={this.attrs.placeholder} - readonly={this.attrs.readonly || undefined} - disabled={this.attrs.disabled || undefined} - {...inputAttrs} - /> + {this.input({ inputClassName, value, inputAttrs })} {this.attrs.loading && } {this.attrs.clearable && value && !this.attrs.loading && (
    {this.tabs()} @@ -165,7 +181,7 @@ export default class SearchModal; + const $input = this.$('SearchModal-input') as JQuery; const cursorPosition = $input.prop('selectionStart') || query.length; const lastChunk = query.slice(0, cursorPosition); const autocomplete = autocompleteReader.check(lastChunk, cursorPosition, /\S+$/); @@ -276,7 +292,7 @@ export default class SearchModal; + const $input = this.inputElement() as JQuery; const query = this.searchState.getValue(); const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length); @@ -291,6 +307,30 @@ export default class SearchModal `lorem ipsum is:unread dolor` + */ + gambifyInput(): Mithril.Children { + const query = this.searchState.getValue(); + let marked = query; + + app.search.gambits.match(this.activeSource().resource, query, (gambit: IGambit, matches: string[], negate: boolean, bit: string) => { + marked = marked.replace(bit, `${bit}`); + }); + + const jsx: Mithril.ChildArray = []; + marked.split(/(.*?<\/mark>)/).forEach((chunk) => { + if (chunk.startsWith('')) { + jsx.push({chunk.replace(/<\/?mark>/g, '')}); + } else { + jsx.push(chunk); + } + }); + + return jsx; + } + onupdate(vnode: Mithril.VnodeDOM) { super.onupdate(vnode); @@ -320,7 +360,7 @@ export default class SearchModal; + const $input = this.inputElement() as JQuery; this.navigator = new KeyboardNavigatable(); this.navigator @@ -371,18 +411,17 @@ export default class SearchModal { + return this.$('.SearchModal-input'); + } } diff --git a/framework/core/js/src/forum/components/UsersSearchSource.tsx b/framework/core/js/src/forum/components/UsersSearchSource.tsx index d70ea6b1ac..60f739f510 100644 --- a/framework/core/js/src/forum/components/UsersSearchSource.tsx +++ b/framework/core/js/src/forum/components/UsersSearchSource.tsx @@ -4,7 +4,7 @@ import app from '../app'; import highlight from '../../common/helpers/highlight'; import username from '../../common/helpers/username'; import Link from '../../common/components/Link'; -import User from '../../common/models/User'; +import type User from '../../common/models/User'; import Avatar from '../../common/components/Avatar'; import type { SearchSource } from './Search'; import extractText from '../../common/utils/extractText'; @@ -57,7 +57,7 @@ export default class UsersSearchResults implements SearchSource { const name = username(user, (name: string) => highlight(name, query)); return ( -
  • +
  • @@ -73,4 +73,12 @@ export default class UsersSearchResults implements SearchSource { 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/less/common/Search.less b/framework/core/less/common/Search.less index 6fab3618f9..f780bfdde8 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -7,6 +7,10 @@ --discussion-list-item-bg-hover: var(--control-bg); margin: 0 -16px; border-radius: 0; + + > .DiscussionListItem-content { + opacity: 1; + } } &.active > .DiscussionListItem { @@ -81,7 +85,9 @@ } &-input { + background: transparent !important; height: 42px; + border-color: var(--form-control-color); } &-results { @@ -118,4 +124,35 @@ color: var(--control-color); } } + + .Input { + z-index: 0; + } +} + + +.SearchModal-visual-wrapper { + position: absolute; + inset: 0; + margin-left: 34px; + margin-right: 32px; + line-height: 42px; + color: transparent; + pointer-events: none; + z-index: -1; + overflow: hidden; +} + +.SearchModal-visual-input { + position: absolute; + inset: 0; + white-space: pre; } + +.SearchModal-visual-input mark { + margin: 0; + padding: 0; + background-color: var(--control-bg-shaded); + color: transparent; +} + From 2e646be3b49bdf696b1ae8eb5012974c5e1f3bee Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Thu, 30 Nov 2023 10:48:23 +0100 Subject: [PATCH 10/19] refactor: localize gambits --- .../common/query/discussions/LockedGambit.ts | 29 +--- .../common/query/discussions/StickyGambit.ts | 29 +--- .../query/discussions/SubscriptionGambit.ts | 27 ++-- .../src/common/query/users/SuspendedGambit.ts | 29 +--- .../src/common/query/discussions/TagGambit.ts | 41 +++--- .../tags/src/Search/Filter/TagFilter.php | 48 +++--- framework/core/js/src/common/GambitManager.ts | 10 +- framework/core/js/src/common/query/IGambit.ts | 137 +++++++++++++++++- .../common/query/discussions/AuthorGambit.ts | 29 +--- .../common/query/discussions/CreatedGambit.ts | 31 ++-- .../common/query/discussions/HiddenGambit.ts | 29 +--- .../common/query/discussions/UnreadGambit.ts | 29 +--- .../js/src/common/query/users/EmailGambit.ts | 29 +--- .../js/src/common/query/users/GroupGambit.ts | 29 +--- .../components/DiscussionsSearchSource.tsx | 2 +- 15 files changed, 251 insertions(+), 277 deletions(-) diff --git a/extensions/lock/js/src/common/query/discussions/LockedGambit.ts b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts index 11c1f93690..6a91875086 100644 --- a/extensions/lock/js/src/common/query/discussions/LockedGambit.ts +++ b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts @@ -1,33 +1,12 @@ -import IGambit, { GambitType } from 'flarum/common/query/IGambit'; +import { BooleanGambit } from 'flarum/common/query/IGambit'; import app from 'flarum/common/app'; -export default class LockedGambit implements IGambit { - type = GambitType.Grouped; - - pattern(): string { - return 'is:locked'; - } - - toFilter(_matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'locked'; - - return { - [key]: true, - }; +export default class LockedGambit extends BooleanGambit { + key(): string { + return app.translator.trans('flarum-lock.lib.gambits.discussions.locked.key', {}, true); } filterKey(): string { return 'locked'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}is:locked`; - } - - suggestion() { - return { - group: 'is', - key: app.translator.trans('flarum-lock.lib.gambits.discussions.locked.key', {}, true), - }; - } } diff --git a/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts index e906542031..96ac8856d7 100644 --- a/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts +++ b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts @@ -1,33 +1,12 @@ -import IGambit, { GambitType } from 'flarum/common/query/IGambit'; +import { BooleanGambit } from 'flarum/common/query/IGambit'; import app from 'flarum/common/app'; -export default class StickyGambit implements IGambit { - type = GambitType.Grouped; - - pattern(): string { - return 'is:sticky'; - } - - toFilter(_matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'sticky'; - - return { - [key]: true, - }; +export default class StickyGambit extends BooleanGambit { + key(): string { + return app.translator.trans('flarum-sticky.lib.gambits.discussions.sticky.key', {}, true); } filterKey(): string { return 'sticky'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}is:sticky`; - } - - suggestion() { - return { - group: 'is', - key: app.translator.trans('flarum-sticky.lib.gambits.discussions.sticky.key', {}, true), - }; - } } diff --git a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts index 8ff560f012..73e93fb1d0 100644 --- a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts +++ b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts @@ -1,18 +1,19 @@ import app from 'flarum/common/app'; -import IGambit, { GambitType } from 'flarum/common/query/IGambit'; +import { BooleanGambit } from 'flarum/common/query/IGambit'; -export default class SubscriptionGambit implements IGambit { - type = GambitType.Grouped; - - pattern(): string { - return 'is:(follow|ignor)(?:ing|ed)'; +export default class SubscriptionGambit extends BooleanGambit { + key(): string[] { + return [ + app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.following_key', {}, true), + app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.ignoring_key', {}, true), + ]; } toFilter(matches: string[], negate: boolean): Record { - const type = matches[1] === 'follow' ? 'following' : 'ignoring'; + const key = (negate ? '-' : '') + this.filterKey(); return { - subscription: type, + [key]: matches[1], }; } @@ -23,14 +24,4 @@ export default class SubscriptionGambit implements IGambit { fromFilter(value: string, negate: boolean): string { return `${negate ? '-' : ''}is:${value}`; } - - suggestion() { - return { - group: 'is', - key: [ - app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.following_key', {}, true), - app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.ignoring_key', {}, true), - ], - }; - } } diff --git a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts index 3c3bae42e9..3fdc1e0f1c 100644 --- a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts +++ b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts @@ -1,33 +1,12 @@ import app from 'flarum/common/app'; -import IGambit, { GambitType } from 'flarum/common/query/IGambit'; +import { BooleanGambit } from 'flarum/common/query/IGambit'; -export default class SuspendedGambit implements IGambit { - type = GambitType.Grouped; - - pattern(): string { - return 'is:suspended'; - } - - toFilter(_matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'suspended'; - - return { - [key]: true, - }; +export default class SuspendedGambit extends BooleanGambit { + key(): string { + return app.translator.trans('flarum-suspend.lib.gambits.users.suspended.key', {}, true); } filterKey(): string { return 'suspended'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}is:suspended`; - } - - suggestion() { - return { - group: 'is', - key: app.translator.trans('flarum-suspend.lib.gambits.users.suspended.key', {}, true), - }; - } } diff --git a/extensions/tags/js/src/common/query/discussions/TagGambit.ts b/extensions/tags/js/src/common/query/discussions/TagGambit.ts index 886fb07676..137e5f3f5b 100644 --- a/extensions/tags/js/src/common/query/discussions/TagGambit.ts +++ b/extensions/tags/js/src/common/query/discussions/TagGambit.ts @@ -1,33 +1,38 @@ import app from 'flarum/common/app'; -import IGambit, { GambitType } from 'flarum/common/query/IGambit'; +import { KeyValueGambit } from 'flarum/common/query/IGambit'; -export default class TagGambit implements IGambit { - type = GambitType.KeyValue; +export default class TagGambit extends KeyValueGambit { + predicates = true; - pattern(): string { - return 'tag:(.+)'; + key(): string { + return app.translator.trans('flarum-tags.lib.gambits.discussions.tag.key', {}, true); } - toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'tag'; - - return { - [key]: matches[1].split(','), - }; + hint(): string { + return app.translator.trans('flarum-tags.lib.gambits.discussions.tag.hint', {}, true); } filterKey(): string { return 'tag'; } - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}tag:${value}`; + gambitValueToFilterValue(value: string): string[] { + return [value]; + } + + fromFilter(value: any, negate: boolean): string { + let gambits = []; + + if (Array.isArray(value)) { + gambits = value.map((value) => this.fromFilter(value.toString(), negate)); + } else { + return `${negate ? '-' : ''}${this.key()}:${this.filterValueToGambitValue(value)}`; + } + + return gambits.join(' '); } - suggestion() { - return { - key: app.translator.trans('flarum-tags.lib.gambits.discussions.tag.key', {}, true), - hint: app.translator.trans('flarum-tags.lib.gambits.discussions.tag.hint', {}, true), - }; + filterValueToGambitValue(value: string): string { + return value; } } diff --git a/extensions/tags/src/Search/Filter/TagFilter.php b/extensions/tags/src/Search/Filter/TagFilter.php index 7bc43e828c..2446cf5025 100644 --- a/extensions/tags/src/Search/Filter/TagFilter.php +++ b/extensions/tags/src/Search/Filter/TagFilter.php @@ -43,30 +43,34 @@ public function filter(SearchState $state, string|array $value, bool $negate): v protected function constrain(Builder $query, string|array $rawSlugs, bool $negate, User $actor): void { - $slugs = $this->asStringArray($rawSlugs); + $inputSlugs = $this->asStringArray($rawSlugs); - $query->where(function (Builder $query) use ($slugs, $negate, $actor) { - foreach ($slugs as $slug) { - if ($slug === 'untagged') { - $query->whereIn('discussions.id', function (Builder $query) { - $query->select('discussion_id') - ->from('discussion_tag'); - }, 'or', ! $negate); - } else { - // @TODO: grab all IDs first instead of multiple queries. - try { - $id = $this->slugger->forResource(Tag::class)->fromSlug($slug, $actor)->id; - } catch (ModelNotFoundException) { - $id = null; - } + foreach ($inputSlugs as $orSlugs) { + $slugs = explode(',', $orSlugs); + + $query->where(function (Builder $query) use ($slugs, $negate, $actor) { + foreach ($slugs as $slug) { + if ($slug === 'untagged') { + $query->whereIn('discussions.id', function (Builder $query) { + $query->select('discussion_id') + ->from('discussion_tag'); + }, 'or', ! $negate); + } else { + // @TODO: grab all IDs first instead of multiple queries. + try { + $id = $this->slugger->forResource(Tag::class)->fromSlug($slug, $actor)->id; + } catch (ModelNotFoundException) { + $id = null; + } - $query->whereIn('discussions.id', function (Builder $query) use ($id) { - $query->select('discussion_id') - ->from('discussion_tag') - ->where('tag_id', $id); - }, 'or', $negate); + $query->whereIn('discussions.id', function (Builder $query) use ($id) { + $query->select('discussion_id') + ->from('discussion_tag') + ->where('tag_id', $id); + }, 'or', $negate); + } } - } - }); + }); + } } } diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts index 9bd055f27c..7f2df6f4b9 100644 --- a/framework/core/js/src/common/GambitManager.ts +++ b/framework/core/js/src/common/GambitManager.ts @@ -20,7 +20,15 @@ export default class GambitManager { public apply(type: string, filter: Record): Record { filter.q = this.match(type, filter.q, (gambit, matches, negate) => { - Object.assign(filter, gambit.toFilter(matches, negate)); + const additions = gambit.toFilter(matches, negate); + + Object.keys(additions).forEach((key) => { + if (key in filter && gambit.predicates && Array.isArray(additions[key])) { + filter[key] = filter[key].concat(additions[key]); + } else { + filter[key] = additions[key]; + } + }); }); return filter; diff --git a/framework/core/js/src/common/query/IGambit.ts b/framework/core/js/src/common/query/IGambit.ts index ded3aec4e1..9014c2971f 100644 --- a/framework/core/js/src/common/query/IGambit.ts +++ b/framework/core/js/src/common/query/IGambit.ts @@ -1,10 +1,58 @@ +import app from '../app'; + export default interface IGambit { type: GambitType; + + /** + * This is the regular expression pattern that will be used to match the gambit. + * The pattern language can be localized. for example, the pattern for the + * author gambit is `author:(.+)` in English, but `auteur:(.+)` in + * French. + */ pattern(): string; + + /** + * This is the method to transform a gambit into a filter format. + */ toFilter(matches: string[], negate: boolean): Record; + + /** + * This is the server standardised filter key for this gambit. + * The filter key must not be localized. + */ filterKey(): string; - fromFilter(value: string, negate: boolean): string; + + /** + * This is the method to transform a filter into a gambit format. + * The gambit format can be localized. + */ + fromFilter(value: any, negate: boolean): string; + + /** + * This returns information about how the gambit is structured for the UI. + * Use localized values. + */ suggestion(): Type extends GambitType.KeyValue ? KeyValueGambitSuggestion : GroupedGambitSuggestion; + + /** + * Whether this gambit can use logical operators. + * For example, the tag gambit can be used as such: + * `tag:foo,bar tag:baz` which translates to `(foo OR bar) AND baz`. + * + * The info allows generation of the correct filtering format, which would be + * ``` + * { + * tag: [ + * 'foo,bar', // OR because of the comma. + * 'baz', // AND because it's a separate item. + * ] + * } + * ``` + * + * The backend filter must be able to handle this format. + * Checkout the TagGambit and TagFilter classes for an example. + */ + predicates: boolean; } export enum GambitType { @@ -21,3 +69,90 @@ export type GroupedGambitSuggestion = { group: 'is' | 'has' | string; key: string | string[]; }; + +export abstract class BooleanGambit implements IGambit { + type = GambitType.Grouped; + predicates = false; + + abstract key(): string | string[]; + abstract filterKey(): string; + + pattern(): string { + const is = app.translator.trans('core.lib.gambits.boolean_key', {}, true); + let key = this.key(); + + if (Array.isArray(key)) { + key = key.join('|'); + } + + return `${is}:(${key})`; + } + + toFilter(_matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + this.filterKey(); + + return { + [key]: true, + }; + } + + fromFilter(value: string, negate: boolean): string { + const is = app.translator.trans('core.lib.gambits.boolean_key', {}, true); + const key = this.key(); + + return `${negate ? '-' : ''}${is}:${key}`; + } + + suggestion() { + return { + group: app.translator.trans('core.lib.gambits.boolean_key', {}, true), + key: this.key(), + }; + } +} + +export abstract class KeyValueGambit implements IGambit { + type = GambitType.KeyValue; + predicates = false; + + abstract key(): string; + abstract hint(): string; + abstract filterKey(): string; + + valuePattern(): string { + return '(.+)'; + } + + gambitValueToFilterValue(value: string): string | number | boolean | Array { + return value; + } + + filterValueToGambitValue(value: any): string { + return Array.isArray(value) ? value.join(',') : value.toString(); + } + + pattern(): string { + const key = this.key(); + + return `${key}:` + this.valuePattern(); + } + + toFilter(matches: string[], negate: boolean): Record { + const key = (negate ? '-' : '') + this.filterKey(); + + return { + [key]: this.gambitValueToFilterValue(matches[1]), + }; + } + + fromFilter(value: any, negate: boolean): string { + return `${negate ? '-' : ''}${this.key()}:${this.filterValueToGambitValue(value)}`; + } + + suggestion() { + return { + key: this.key(), + hint: this.hint(), + }; + } +} diff --git a/framework/core/js/src/common/query/discussions/AuthorGambit.ts b/framework/core/js/src/common/query/discussions/AuthorGambit.ts index 050c710a97..10842a8f08 100644 --- a/framework/core/js/src/common/query/discussions/AuthorGambit.ts +++ b/framework/core/js/src/common/query/discussions/AuthorGambit.ts @@ -1,33 +1,16 @@ import app from '../../app'; -import IGambit, { GambitType } from '../IGambit'; +import { KeyValueGambit } from '../IGambit'; -export default class AuthorGambit implements IGambit { - type = GambitType.KeyValue; - - public pattern(): string { - return 'author:(.+)'; +export default class AuthorGambit extends KeyValueGambit { + key(): string { + return app.translator.trans('core.lib.gambits.discussions.author.key', {}, true); } - public toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'author'; - - return { - [key]: matches[1].split(','), - }; + hint(): string { + return app.translator.trans('core.lib.gambits.discussions.author.hint', {}, true); } filterKey(): string { return 'author'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}author:${value}`; - } - - suggestion() { - return { - key: app.translator.trans('core.lib.gambits.discussions.author.key', {}, true), - hint: app.translator.trans('core.lib.gambits.discussions.author.hint', {}, true), - }; - } } diff --git a/framework/core/js/src/common/query/discussions/CreatedGambit.ts b/framework/core/js/src/common/query/discussions/CreatedGambit.ts index e71ac32265..b7d4374aae 100644 --- a/framework/core/js/src/common/query/discussions/CreatedGambit.ts +++ b/framework/core/js/src/common/query/discussions/CreatedGambit.ts @@ -1,33 +1,20 @@ import app from '../../app'; -import IGambit, { GambitType } from '../IGambit'; +import { KeyValueGambit } from '../IGambit'; -export default class CreatedGambit implements IGambit { - type = GambitType.KeyValue; - - pattern(): string { - return 'created:(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)'; +export default class CreatedGambit extends KeyValueGambit { + key(): string { + return app.translator.trans('core.lib.gambits.discussions.created.key', {}, true); } - toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'created'; + hint(): string { + return app.translator.trans('core.lib.gambits.discussions.created.hint', {}, true); + } - return { - [key]: matches[1], - }; + valuePattern(): string { + return '(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)'; } filterKey(): string { return 'created'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}created:${value}`; - } - - suggestion() { - return { - key: app.translator.trans('core.lib.gambits.discussions.created.key', {}, true), - hint: app.translator.trans('core.lib.gambits.discussions.created.hint', {}, true), - }; - } } diff --git a/framework/core/js/src/common/query/discussions/HiddenGambit.ts b/framework/core/js/src/common/query/discussions/HiddenGambit.ts index 046ec5ca54..02aaad0ff6 100644 --- a/framework/core/js/src/common/query/discussions/HiddenGambit.ts +++ b/framework/core/js/src/common/query/discussions/HiddenGambit.ts @@ -1,33 +1,12 @@ import app from '../../app'; -import IGambit, { GambitType } from '../IGambit'; +import { BooleanGambit } from '../IGambit'; -export default class HiddenGambit implements IGambit { - type = GambitType.Grouped; - - public pattern(): string { - return 'is:hidden'; - } - - public toFilter(_matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'hidden'; - - return { - [key]: true, - }; +export default class HiddenGambit extends BooleanGambit { + key(): string { + return app.translator.trans('core.lib.gambits.discussions.hidden.key', {}, true); } filterKey(): string { return 'hidden'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}is:hidden`; - } - - suggestion() { - return { - group: 'is', - key: app.translator.trans('core.lib.gambits.discussions.hidden.key', {}, true), - }; - } } diff --git a/framework/core/js/src/common/query/discussions/UnreadGambit.ts b/framework/core/js/src/common/query/discussions/UnreadGambit.ts index f3dc0e6f11..ce1263f3d5 100644 --- a/framework/core/js/src/common/query/discussions/UnreadGambit.ts +++ b/framework/core/js/src/common/query/discussions/UnreadGambit.ts @@ -1,33 +1,12 @@ import app from '../../app'; -import IGambit, { GambitType } from '../IGambit'; +import { BooleanGambit } from '../IGambit'; -export default class UnreadGambit implements IGambit { - type = GambitType.Grouped; - - pattern(): string { - return 'is:unread'; - } - - toFilter(_matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'unread'; - - return { - [key]: true, - }; +export default class UnreadGambit extends BooleanGambit { + key(): string { + return app.translator.trans('core.lib.gambits.discussions.unread.key', {}, true); } filterKey(): string { return 'unread'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}is:unread`; - } - - suggestion() { - return { - group: 'is', - key: app.translator.trans('core.lib.gambits.discussions.unread.key', {}, true), - }; - } } diff --git a/framework/core/js/src/common/query/users/EmailGambit.ts b/framework/core/js/src/common/query/users/EmailGambit.ts index 9a3c4f12c5..3f7836c524 100644 --- a/framework/core/js/src/common/query/users/EmailGambit.ts +++ b/framework/core/js/src/common/query/users/EmailGambit.ts @@ -1,33 +1,16 @@ import app from '../../app'; -import IGambit, { GambitType } from '../IGambit'; +import { KeyValueGambit } from '../IGambit'; -export default class EmailGambit implements IGambit { - type = GambitType.KeyValue; - - pattern(): string { - return 'email:(.+)'; +export default class EmailGambit extends KeyValueGambit { + key(): string { + return app.translator.trans('core.lib.gambits.users.email.key', {}, true); } - toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'email'; - - return { - [key]: matches[1], - }; + hint(): string { + return app.translator.trans('core.lib.gambits.users.email.hint', {}, true); } filterKey(): string { return 'email'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}email:${value}`; - } - - suggestion() { - return { - key: app.translator.trans('core.lib.gambits.users.email.key', {}, true), - hint: app.translator.trans('core.lib.gambits.users.email.hint', {}, true), - }; - } } diff --git a/framework/core/js/src/common/query/users/GroupGambit.ts b/framework/core/js/src/common/query/users/GroupGambit.ts index 59c5417ab0..4966581427 100644 --- a/framework/core/js/src/common/query/users/GroupGambit.ts +++ b/framework/core/js/src/common/query/users/GroupGambit.ts @@ -1,33 +1,16 @@ import app from '../../app'; -import IGambit, { GambitType } from '../IGambit'; +import { KeyValueGambit } from '../IGambit'; -export default class GroupGambit implements IGambit { - type = GambitType.KeyValue; - - pattern(): string { - return 'group:(.+)'; +export default class GroupGambit extends KeyValueGambit { + key(): string { + return app.translator.trans('core.lib.gambits.users.group.key', {}, true); } - toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'group'; - - return { - [key]: matches[1].split(','), - }; + hint(): string { + return app.translator.trans('core.lib.gambits.users.group.hint', {}, true); } filterKey(): string { return 'group'; } - - fromFilter(value: string, negate: boolean): string { - return `${negate ? '-' : ''}group:${value}`; - } - - suggestion() { - return { - key: app.translator.trans('core.lib.gambits.users.group.key', {}, true), - hint: app.translator.trans('core.lib.gambits.users.group.hint', {}, true), - }; - } } diff --git a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx index f0d25c9413..478bcf7869 100644 --- a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx @@ -31,7 +31,7 @@ export default class DiscussionsSearchSource implements SearchSource { const params = { filter: { q: query }, page: { limit }, - include: 'mostRelevantPost,user,firstPost', + include: 'mostRelevantPost,user,firstPost,tags', }; return app.store.find('discussions', params).then((results) => { From 9d969bf20889eca0e1a7b206ca9de73cfcbb7458 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Thu, 30 Nov 2023 15:06:03 +0100 Subject: [PATCH 11/19] feat: negative and positive gambit buttons --- extensions/tags/locale/en.yml | 2 +- .../js/src/forum/components/SearchModal.tsx | 55 +++++++++++++------ framework/core/less/common/Dropdown.less | 2 +- framework/core/less/common/Search.less | 27 ++++++++- framework/core/locale/core.yml | 2 + 5 files changed, 67 insertions(+), 21 deletions(-) diff --git a/extensions/tags/locale/en.yml b/extensions/tags/locale/en.yml index fb85e4b294..f07144a1e2 100644 --- a/extensions/tags/locale/en.yml +++ b/extensions/tags/locale/en.yml @@ -112,7 +112,7 @@ flarum-tags: discussions: tag: key: tag - hint: name of the tag + hint: name of a tag, or comma-separated list of tag names, or "untagged" # These translations are used in the tag selection modal. tag_selection_modal: diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index 0fcdeb554e..d32edc5c89 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -220,6 +220,7 @@ export default class SearchModal '', toFilter: () => [], fromFilter: () => '', + predicates: false, }); } @@ -251,7 +252,7 @@ export default class SearchModal !groupQuery || key.toLowerCase().startsWith(groupQuery)) .map((gambit) => - this.gambitSuggestions(gambit, null, () => this.suggest(gambit, groupQuery, autocomplete!.relativeStart + autocomplete!.typed.length)) + this.gambitSuggestion(gambit, null, () => this.suggest(gambit, groupQuery, autocomplete!.relativeStart + autocomplete!.typed.length)) ); } } @@ -271,22 +272,40 @@ export default class SearchModal - this.suggest(key + ':', typed || '', (autocomplete?.relativeStart ?? cursorPosition) + Number(negative)) + return this.gambitSuggestion(key, hint, (negated: boolean | undefined) => + this.suggest(((!!negated && '-') || '') + key + ':', typed || '', (autocomplete?.relativeStart ?? cursorPosition) + Number(negative)) ); }); } - gambitSuggestions(key: string, value: string | null, suggest: () => void): JSX.Element { + gambitSuggestion(key: string, value: string | null, suggest: (negated?: boolean) => void): JSX.Element { return (
  • - + + + {!!value && ( + +
  • ); } @@ -337,6 +356,13 @@ export default class SearchModal li:not(.Dropdown-header):not(.Dropdown-message)', function () { + component.setIndex(component.selectableItems().index(this)); + }); + // If there are no sources, the search view is not shown. if (!this.sources?.length) return; } @@ -348,18 +374,11 @@ export default class SearchModal li:not(.Dropdown-header):not(.Dropdown-message)', function () { - component.setIndex(component.selectableItems().index(this)); - }); - const $input = this.inputElement() as JQuery; this.navigator = new KeyboardNavigatable(); diff --git a/framework/core/less/common/Dropdown.less b/framework/core/less/common/Dropdown.less index d62efc72bb..a47a258aeb 100644 --- a/framework/core/less/common/Dropdown.less +++ b/framework/core/less/common/Dropdown.less @@ -62,7 +62,7 @@ } } &.active { - > a, > button { + > a, > button, > .Dropdown-item { background: var(--control-bg); } } diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index f780bfdde8..56267a6914 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -115,14 +115,35 @@ } &-gambit { - gap: 4px !important; + display: flex; + align-items: center; + > button { + flex-grow: 1; + cursor: pointer; + gap: 4px; + display: flex; + align-items: center; + padding: 8px 15px; + margin: -8px 0 -8px -15px; + } &-key { font-weight: bold; } &-value { color: var(--control-color); } + &-actions { + flex-shrink: 0; + display: flex; + color: var(--control-color); + visibility: hidden; + margin-inline-end: -14px; + + .Button { + margin: -8px 0; + } + } } .Input { @@ -130,6 +151,10 @@ } } +li.active .SearchModal-gambit-actions { + visibility: visible; +} + .SearchModal-visual-wrapper { position: absolute; diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index db359157ee..152cc21649 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -521,6 +521,8 @@ core: # These translations are used by the search modal. search: + gambit_plus_button_a11y_label: Add a positive filter + gambit_minus_button_a11y_label: Add a negative filter title: Search no_results_text: It looks like there are no results here. no_search_text: You have not searched for anything yet. From 669da3426e62c3b89e6e57cbb8eb1ebf89eb95f2 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Thu, 30 Nov 2023 17:07:18 +0100 Subject: [PATCH 12/19] fix: permissions --- .../common/query/discussions/SubscriptionGambit.ts | 4 ++++ extensions/suspend/extend.php | 6 ++++++ extensions/suspend/js/src/@types/shims.d.ts | 7 +++++++ extensions/suspend/js/src/common/extend.ts | 7 ++++++- .../js/src/common/query/users/SuspendedGambit.ts | 4 ++++ extensions/suspend/js/src/forum/extend.ts | 1 - framework/core/js/src/common/GambitManager.ts | 2 +- framework/core/js/src/common/query/IGambit.ts | 13 +++++++++++++ .../js/src/common/query/discussions/HiddenGambit.ts | 4 ++++ .../js/src/common/query/discussions/UnreadGambit.ts | 4 ++++ .../core/js/src/common/query/users/EmailGambit.ts | 4 ++++ .../core/js/src/forum/components/IndexPage.tsx | 2 ++ .../core/js/src/forum/components/SearchModal.tsx | 7 ++++--- framework/core/less/common/Button.less | 2 +- framework/core/less/forum/Post.less | 3 +++ framework/core/locale/core.yml | 4 ++-- .../core/src/Api/Serializer/ForumSerializer.php | 1 + .../core/src/User/Search/Filter/EmailFilter.php | 2 +- 18 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 extensions/suspend/js/src/@types/shims.d.ts diff --git a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts index 73e93fb1d0..4fd449a7c5 100644 --- a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts +++ b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts @@ -24,4 +24,8 @@ export default class SubscriptionGambit extends BooleanGambit { fromFilter(value: string, negate: boolean): string { return `${negate ? '-' : ''}is:${value}`; } + + enabled(): boolean { + return !!app.session.user; + } } diff --git a/extensions/suspend/extend.php b/extensions/suspend/extend.php index fe4c1c87e3..57670b38bb 100644 --- a/extensions/suspend/extend.php +++ b/extensions/suspend/extend.php @@ -8,6 +8,7 @@ */ use Flarum\Api\Serializer\BasicUserSerializer; +use Flarum\Api\Serializer\ForumSerializer; use Flarum\Api\Serializer\UserSerializer; use Flarum\Extend; use Flarum\Search\Database\DatabaseSearchDriver; @@ -41,6 +42,11 @@ (new Extend\ApiSerializer(UserSerializer::class)) ->attributes(AddUserSuspendAttributes::class), + (new Extend\ApiSerializer(ForumSerializer::class)) + ->attribute('canSuspendUsers', function (ForumSerializer $serializer) { + return $serializer->getActor()->hasPermission('user.suspend'); + }), + new Extend\Locales(__DIR__.'/locale'), (new Extend\Notification()) diff --git a/extensions/suspend/js/src/@types/shims.d.ts b/extensions/suspend/js/src/@types/shims.d.ts new file mode 100644 index 0000000000..f14b955bdb --- /dev/null +++ b/extensions/suspend/js/src/@types/shims.d.ts @@ -0,0 +1,7 @@ +import 'flarum/common/models/User'; + +declare module 'flarum/common/models/User' { + export default interface User { + canSuspend: () => boolean; + } +} diff --git a/extensions/suspend/js/src/common/extend.ts b/extensions/suspend/js/src/common/extend.ts index c576d539fb..f1a55ca147 100644 --- a/extensions/suspend/js/src/common/extend.ts +++ b/extensions/suspend/js/src/common/extend.ts @@ -1,7 +1,12 @@ import Extend from 'flarum/common/extenders'; import SuspendedGambit from './query/users/SuspendedGambit'; +import User from 'flarum/common/models/User'; +// prettier-ignore export default [ - new Extend.Search() // + new Extend.Search() .gambit('users', SuspendedGambit), + + new Extend.Model(User) + .attribute('canSuspend'), ]; diff --git a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts index 3fdc1e0f1c..c530fbe268 100644 --- a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts +++ b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts @@ -9,4 +9,8 @@ export default class SuspendedGambit extends BooleanGambit { filterKey(): string { return 'suspended'; } + + enabled(): boolean { + return !!app.session.user && app.forum.attribute('canSuspendUsers'); + } } diff --git a/extensions/suspend/js/src/forum/extend.ts b/extensions/suspend/js/src/forum/extend.ts index d4de0dec11..8432a3462b 100644 --- a/extensions/suspend/js/src/forum/extend.ts +++ b/extensions/suspend/js/src/forum/extend.ts @@ -8,7 +8,6 @@ export default [ ...commonExtend, new Extend.Model(User) - .attribute('canSuspend') .attribute('suspendedUntil', Model.transformDate) .attribute('suspendReason') .attribute('suspendMessage'), diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts index 7f2df6f4b9..3609dc5b6f 100644 --- a/framework/core/js/src/common/GambitManager.ts +++ b/framework/core/js/src/common/GambitManager.ts @@ -35,7 +35,7 @@ export default class GambitManager { } public match(type: string, query: string, onmatch: (gambit: IGambit, matches: string[], negate: boolean, bit: string) => void): string { - const gambits = this.for(type); + const gambits = this.for(type).filter((gambit) => gambit.enabled()); if (gambits.length === 0) return query; diff --git a/framework/core/js/src/common/query/IGambit.ts b/framework/core/js/src/common/query/IGambit.ts index 9014c2971f..5f152f5c4d 100644 --- a/framework/core/js/src/common/query/IGambit.ts +++ b/framework/core/js/src/common/query/IGambit.ts @@ -53,6 +53,11 @@ export default interface IGambit { * Checkout the TagGambit and TagFilter classes for an example. */ predicates: boolean; + + /** + * Whether this gambit can be used by the actor. + */ + enabled(): boolean; } export enum GambitType { @@ -109,6 +114,10 @@ export abstract class BooleanGambit implements IGambit { key: this.key(), }; } + + enabled(): boolean { + return true; + } } export abstract class KeyValueGambit implements IGambit { @@ -155,4 +164,8 @@ export abstract class KeyValueGambit implements IGambit { hint: this.hint(), }; } + + enabled(): boolean { + return true; + } } diff --git a/framework/core/js/src/common/query/discussions/HiddenGambit.ts b/framework/core/js/src/common/query/discussions/HiddenGambit.ts index 02aaad0ff6..a29579cc8a 100644 --- a/framework/core/js/src/common/query/discussions/HiddenGambit.ts +++ b/framework/core/js/src/common/query/discussions/HiddenGambit.ts @@ -9,4 +9,8 @@ export default class HiddenGambit extends BooleanGambit { filterKey(): string { return 'hidden'; } + + enabled(): boolean { + return !!app.session.user; + } } diff --git a/framework/core/js/src/common/query/discussions/UnreadGambit.ts b/framework/core/js/src/common/query/discussions/UnreadGambit.ts index ce1263f3d5..2dee136e26 100644 --- a/framework/core/js/src/common/query/discussions/UnreadGambit.ts +++ b/framework/core/js/src/common/query/discussions/UnreadGambit.ts @@ -9,4 +9,8 @@ export default class UnreadGambit extends BooleanGambit { filterKey(): string { return 'unread'; } + + enabled(): boolean { + return !!app.session.user; + } } diff --git a/framework/core/js/src/common/query/users/EmailGambit.ts b/framework/core/js/src/common/query/users/EmailGambit.ts index 3f7836c524..4db9bc92b0 100644 --- a/framework/core/js/src/common/query/users/EmailGambit.ts +++ b/framework/core/js/src/common/query/users/EmailGambit.ts @@ -13,4 +13,8 @@ export default class EmailGambit extends KeyValueGambit { filterKey(): string { return 'email'; } + + enabled(): boolean { + return !!(app.session.user && app.forum.attribute('canEditUserCredentials')); + } } diff --git a/framework/core/js/src/forum/components/IndexPage.tsx b/framework/core/js/src/forum/components/IndexPage.tsx index ca1fc79613..7b6fad76c0 100644 --- a/framework/core/js/src/forum/components/IndexPage.tsx +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -184,6 +184,7 @@ export default class IndexPage { @@ -201,6 +202,7 @@ export default class IndexPage gambit.enabled()); const query = this.searchState.getValue(); // We group the boolean gambits together to produce an initial item of @@ -221,6 +221,7 @@ export default class SearchModal [], fromFilter: () => '', predicates: false, + enabled: () => true, }); } @@ -430,9 +431,9 @@ export default class SearchModal $this->actor->can('searchUsers'), 'canCreateAccessToken' => $this->actor->can('createAccessToken'), 'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'), + 'canEditUserCredentials' => $this->actor->hasPermission('user.editCredentials'), 'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'), 'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'), ]; diff --git a/framework/core/src/User/Search/Filter/EmailFilter.php b/framework/core/src/User/Search/Filter/EmailFilter.php index c0e4eb49fe..90e446c2f4 100644 --- a/framework/core/src/User/Search/Filter/EmailFilter.php +++ b/framework/core/src/User/Search/Filter/EmailFilter.php @@ -29,7 +29,7 @@ public function getFilterKey(): string public function filter(SearchState $state, string|array $value, bool $negate): void { - if (! $state->getActor()->hasPermission('user.edit')) { + if (! $state->getActor()->hasPermission('user.editCredentials')) { return; } From 2c7264c280c90b47084390a7f994b93eada48e93 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Thu, 30 Nov 2023 17:07:38 +0100 Subject: [PATCH 13/19] chore: wat --- framework/core/js/src/forum/components/SearchModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index f85a550936..e65cf29c62 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -14,7 +14,6 @@ import LoadingIndicator from '../../common/components/LoadingIndicator'; import type { SearchSource } from './Search'; import IGambit, { GambitType, GroupedGambitSuggestion, KeyValueGambitSuggestion } from '../../common/query/IGambit'; import AutocompleteReader from '../../common/utils/AutocompleteReader'; -import { electron } from 'webpack'; export interface ISearchModalAttrs extends IFormModalAttrs { onchange: (value: string) => void; From 348d616d960f47f130c580c78b8efc11f77eafb6 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Thu, 30 Nov 2023 17:12:53 +0100 Subject: [PATCH 14/19] per: lazy load search modal --- framework/core/js/src/forum/components/Search.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index 081a5d36ed..e5c455807e 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -112,7 +112,8 @@ export default class Search extends Compone inputAttrs={{ onfocus: () => setTimeout(() => { - this.$('input').blur() && app.modal.show(SearchModal, { searchState: this.searchState, sources: this.sourceItems().toArray() }); + this.$('input').blur() && + app.modal.show(() => import('./SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() }); }, 150), }} /> From d1cea6d56e2f2120097a327ba7083a570611b878 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 1 Dec 2023 11:21:57 +0100 Subject: [PATCH 15/19] fix: extensibility and bug fixes --- .../js/src/forum/components/SearchModal.tsx | 181 +++++++++++------- 1 file changed, 114 insertions(+), 67 deletions(-) diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index e65cf29c62..06db59ca48 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -12,8 +12,10 @@ import Stream from '../../common/utils/Stream'; import InfoTile from '../../common/components/InfoTile'; import LoadingIndicator from '../../common/components/LoadingIndicator'; import type { SearchSource } from './Search'; -import IGambit, { GambitType, GroupedGambitSuggestion, KeyValueGambitSuggestion } from '../../common/query/IGambit'; -import AutocompleteReader from '../../common/utils/AutocompleteReader'; +import type IGambit from '../../common/query/IGambit'; +import { GambitType, type GroupedGambitSuggestion, type KeyValueGambitSuggestion } from '../../common/query/IGambit'; +import AutocompleteReader, { type AutocompleteCheck } from '../../common/utils/AutocompleteReader'; +import ItemList from '../../common/utils/ItemList'; export interface ISearchModalAttrs extends IFormModalAttrs { onchange: (value: string) => void; @@ -114,66 +116,90 @@ export default class SearchModal -
    - {this.sources?.map((source) => ( - - ))} -
    - {this.activeTab()} +
    {this.tabItems().toArray()}
    +
    {this.activeTabItems().toArray()}
    ); } - activeTab(): JSX.Element { + tabItems(): ItemList { + const items = new ItemList(); + + this.sources?.map((source, index) => + items.add( + source.resource, + , + 100 - index + ) + ); + + return items; + } + + activeTabItems(): ItemList { + const items = new ItemList(); + const loading = this.loadingSources.includes(this.activeSource().resource); const shouldShowResults = !!this.searchState.getValue() && !loading; const gambits = this.gambits(); const fullPageLink = this.activeSource().fullPage(this.searchState.getValue()); const results = this.activeSource()?.view(this.searchState.getValue()); - return ( -
    - {shouldShowResults && fullPageLink && ( -
    -
    -
      {fullPageLink}
    -
    - )} - {!!gambits.length && ( -
    -
    -
      -
    • {app.translator.trans('core.forum.search.options_heading')}
    • - {gambits} -
    -
    - )} + if (shouldShowResults && fullPageLink) { + items.add( + 'fullPageLink',

    -
      -
    • {app.translator.trans('core.forum.search.preview_heading')}
    • - {!shouldShowResults && ( -
    • - {app.translator.trans('core.forum.search.no_search_text')} -
    • - )} - {shouldShowResults && results} - {shouldShowResults && !results?.length && ( -
    • - {app.translator.trans('core.forum.search.no_results_text')} -
    • - )} - {loading && ( -
    • - -
    • - )} +
        {fullPageLink}
      +
    , + 80 + ); + } + + if (!!gambits.length) { + items.add( + 'gambits', +
    +
    +
      +
    • {app.translator.trans('core.forum.search.options_heading')}
    • + {gambits}
    -
    -
    + , + 60 + ); + } + + items.add( + 'results', +
    +
    +
      +
    • {app.translator.trans('core.forum.search.preview_heading')}
    • + {!shouldShowResults && ( +
    • + {app.translator.trans('core.forum.search.no_search_text')} +
    • + )} + {shouldShowResults && results} + {shouldShowResults && !results?.length && ( +
    • + {app.translator.trans('core.forum.search.no_results_text')} +
    • + )} + {loading && ( +
    • + +
    • + )} +
    +
    , + 40 ); + + return items; } switchSource(source: SearchSource) { @@ -241,19 +267,15 @@ export default class SearchModal gambit.suggestion().group === groupName) - .flatMap((gambit): string[] => - gambit.suggestion().key instanceof Array ? (gambit.suggestion().key as string[]) : [gambit.suggestion().key as string] - ) - .filter((key) => !groupQuery || key.toLowerCase().startsWith(groupQuery)) - .map((gambit) => - this.gambitSuggestion(gambit, null, () => this.suggest(gambit, groupQuery, autocomplete!.relativeStart + autocomplete!.typed.length)) - ); + const gambitKey = typed.replace(/:$/, '') || null; + const gambitQuery = typed.split(':').pop() || ''; + + if (gambitKey) { + const specificGambitSuggestions = this.specificGambitSuggestions(gambitKey, gambitQuery, uniqueGroups, groupedGambits, autocomplete!); + + if (specificGambitSuggestions) { + return specificGambitSuggestions; + } } } @@ -278,6 +300,28 @@ export default class SearchModal[], + autocomplete: AutocompleteCheck + ): JSX.Element[] | null { + if (uniqueGroups.includes(gambitKey)) { + return groupedGambits + .filter((gambit) => gambit.suggestion().group === gambitKey) + .flatMap((gambit): string[] => + gambit.suggestion().key instanceof Array ? (gambit.suggestion().key as string[]) : [gambit.suggestion().key as string] + ) + .filter((key) => !gambitQuery || key.toLowerCase().startsWith(gambitQuery)) + .map((gambit) => + this.gambitSuggestion(gambit, null, () => this.suggest(gambit, gambitQuery, autocomplete.relativeStart + autocomplete.typed.length)) + ); + } + + return null; + } + gambitSuggestion(key: string, value: string | null, suggest: (negated?: boolean) => void): JSX.Element { return (
  • @@ -430,15 +474,18 @@ export default class SearchModal Date: Fri, 1 Dec 2023 11:37:27 +0100 Subject: [PATCH 16/19] fix: bugs --- .../core/js/src/forum/components/Search.tsx | 2 +- .../js/src/forum/components/SearchModal.tsx | 34 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index e5c455807e..3f6cb19495 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -94,7 +94,7 @@ export default class Search extends Compone const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder')); return ( -
    +
    ; + /** * An array of SearchSources. */ @@ -62,6 +64,7 @@ export default class SearchModal { - this.searchState.setValue(value); + this.query(value); this.inputScroll(this.inputElement()[0]?.scrollLeft ?? 0); }} inputAttrs={{ className: 'SearchModal-input' }} @@ -142,10 +145,10 @@ export default class SearchModal(); const loading = this.loadingSources.includes(this.activeSource().resource); - const shouldShowResults = !!this.searchState.getValue() && !loading; + const shouldShowResults = !!this.query() && !loading; const gambits = this.gambits(); - const fullPageLink = this.activeSource().fullPage(this.searchState.getValue()); - const results = this.activeSource()?.view(this.searchState.getValue()); + const fullPageLink = this.activeSource().fullPage(this.query()); + const results = this.activeSource()?.view(this.query()); if (shouldShowResults && fullPageLink) { items.add( @@ -205,7 +208,7 @@ export default class SearchModal gambit.enabled()); - const query = this.searchState.getValue(); + const query = this.query(); // We group the boolean gambits together to produce an initial item of // is:unread,sticky,locked, etc. @@ -357,10 +360,10 @@ export default class SearchModal; - const query = this.searchState.getValue(); + const query = this.query(); const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length); - this.searchState.setValue(replaced); + this.query(replaced); $input[0].focus(); setTimeout(() => { $input[0].setSelectionRange(start + text.length, start + text.length); @@ -375,7 +378,7 @@ export default class SearchModal `lorem ipsum is:unread dolor` */ gambifyInput(): Mithril.Children { - const query = this.searchState.getValue(); + const query = this.query(); let marked = query; app.search.gambits.match(this.activeSource().resource, query, (gambit: IGambit, matches: string[], negate: boolean, bit: string) => { @@ -439,6 +442,11 @@ export default class SearchModal) { + this.searchState.setValue(this.query()); + super.onremove(vnode); + } + search(query: string) { if (!query) return; @@ -484,7 +492,9 @@ export default class SearchModal Date: Fri, 1 Dec 2023 15:55:58 +0100 Subject: [PATCH 17/19] feat: reusable autocomplete dropdown --- .../js/src/admin/components/UserListPage.tsx | 29 +-- framework/core/js/src/common/common.ts | 3 + .../components/AutocompleteDropdown.tsx | 201 ++++++++++++++++++ .../GambitsAutocompleteDropdown.tsx | 28 +++ .../src/common/utils/GambitsAutocomplete.tsx | 174 +++++++++++++++ .../js/src/forum/components/SearchModal.tsx | 173 ++------------- framework/core/less/common/Dropdown.less | 42 ++++ framework/core/less/common/Search.less | 37 ---- 8 files changed, 478 insertions(+), 209 deletions(-) create mode 100644 framework/core/js/src/common/components/AutocompleteDropdown.tsx create mode 100644 framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx create mode 100644 framework/core/js/src/common/utils/GambitsAutocomplete.tsx diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index 6d1c1a0258..9c4cad77e7 100644 --- a/framework/core/js/src/admin/components/UserListPage.tsx +++ b/framework/core/js/src/admin/components/UserListPage.tsx @@ -18,6 +18,7 @@ import { debounce } from '../../common/utils/throttleDebounce'; import CreateUserModal from './CreateUserModal'; import Icon from '../../common/components/Icon'; import Input from '../../common/components/Input'; +import GambitsAutocompleteDropdown from '../../common/components/GambitsAutocompleteDropdown'; type ColumnData = { /** @@ -235,20 +236,24 @@ export default class UserListPage extends AdminPage { headerItems(): ItemList { const items = new ItemList(); + const onchange = (value: string) => { + this.isLoadingPage = true; + this.query = value; + this.throttledSearch(); + }; + items.add( 'search', - { - this.isLoadingPage = true; - this.query = value; - this.throttledSearch(); - }} - />, + + + , 100 ); diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index cec88e450f..70fcb68372 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -8,6 +8,7 @@ import './states/PageState'; import './states/SearchState'; import './utils/AutocompleteReader'; +import './utils/GambitsAutocomplete'; import './utils/isObject'; import './utils/mixin'; import './utils/insertText'; @@ -73,6 +74,8 @@ import './components/GroupBadge'; import './components/TextEditor'; import './components/TextEditorButton'; import './components/Tooltip'; +import './components/AutocompleteDropdown'; +import './components/GambitsAutocompleteDropdown'; import './helpers/fullTime'; import './components/Avatar'; diff --git a/framework/core/js/src/common/components/AutocompleteDropdown.tsx b/framework/core/js/src/common/components/AutocompleteDropdown.tsx new file mode 100644 index 0000000000..88f01c90f5 --- /dev/null +++ b/framework/core/js/src/common/components/AutocompleteDropdown.tsx @@ -0,0 +1,201 @@ +import Component, { type ComponentAttrs } from '../Component'; +import KeyboardNavigatable from '../utils/KeyboardNavigatable'; +import type Mithril from 'mithril'; +import classList from '../utils/classList'; + +export interface AutocompleteDropdownAttrs extends ComponentAttrs { + query: string; + onchange: (value: string) => void; +} + +/** + * A reusable component that wraps around an input element and displays a list + * of suggestions based on the input's value. + * Must be extended and the `suggestions` method implemented. + */ +export default abstract class AutocompleteDropdown< + CustomAttrs extends AutocompleteDropdownAttrs = AutocompleteDropdownAttrs +> extends Component { + /** + * 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; + + private updateMaxHeightHandler?: () => void; + + /** + * Whether the input has focus. + */ + protected hasFocus = false; + + abstract suggestions(): JSX.Element[]; + + view(vnode: Mithril.Vnode): Mithril.Children { + const suggestions = this.suggestions(); + const shouldShowSuggestions = !!suggestions.length; + + return ( +
    + {vnode.children} +
      + {suggestions} +
    +
    + ); + } + + 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('.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin; + + this.element.querySelector('.Dropdown-suggestions')?.style?.setProperty('max-height', `${maxHeight}px`); + } + + onupdate(vnode: Mithril.VnodeDOM) { + super.onupdate(vnode); + + // Highlight the item that is currently selected. + this.setIndex(this.getCurrentNumericIndex()); + + this.updateMaxHeight(); + } + + oncreate(vnode: Mithril.VnodeDOM) { + super.oncreate(vnode); + + const component = this; + + // Highlight the item that is currently selected. + this.setIndex(this.getCurrentNumericIndex()); + + this.$('.Dropdown-suggestions') + .on('mousedown', (e) => e.preventDefault()) + // Whenever the mouse is hovered over a search result, highlight it. + .on('mouseenter', '> li:not(.Dropdown-header)', function () { + component.setIndex(component.selectableItems().index(this)); + }); + + const $input = this.inputElement(); + + this.navigator = new KeyboardNavigatable(); + this.navigator + .onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true)) + .onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true)) + .onSelect(this.selectSuggestion.bind(this), true) + .bindTo($input); + + $input + .on('focus', function () { + component.hasFocus = true; + m.redraw(); + + $(this) + .one('mouseup', (e) => e.preventDefault()) + .trigger('select'); + }) + .on('blur', function () { + component.hasFocus = false; + m.redraw(); + }); + + 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); + } + } + + selectableItems(): JQuery { + return this.$('.Dropdown-suggestions > li:not(.Dropdown-header)'); + } + + inputElement(): JQuery { + return this.$('input') as JQuery; + } + + selectSuggestion() { + this.getItem(this.index).find('button')[0].click(); + } + + /** + * Get the position of the currently selected 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); + } + + 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); + } + } + } +} diff --git a/framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx b/framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx new file mode 100644 index 0000000000..18f9b9ef64 --- /dev/null +++ b/framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx @@ -0,0 +1,28 @@ +import type Mithril from 'mithril'; +import AutocompleteDropdown, { type AutocompleteDropdownAttrs } from './AutocompleteDropdown'; +import GambitsAutocomplete from '../utils/GambitsAutocomplete'; + +export interface GambitsAutocompleteDropdownAttrs extends AutocompleteDropdownAttrs { + resource: string; +} + +/** + * This is an autocomplete component not related to the SearchModal forum components. + * It is a standalone component that can be reused for search inputs of any other types + * of resources. It will display a dropdown menu under the input with gambit suggestions + * similar to the SearchModal component. + */ +export default class GambitsAutocompleteDropdown< + CustomAttrs extends GambitsAutocompleteDropdownAttrs = GambitsAutocompleteDropdownAttrs +> extends AutocompleteDropdown { + protected gambitsAutocomplete!: GambitsAutocomplete; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + this.gambitsAutocomplete = new GambitsAutocomplete(this.attrs.resource, () => this.inputElement(), this.attrs.onchange, this.attrs.onchange); + } + + suggestions(): JSX.Element[] { + return this.gambitsAutocomplete.suggestions(this.attrs.query); + } +} diff --git a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx new file mode 100644 index 0000000000..2daa8ab785 --- /dev/null +++ b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx @@ -0,0 +1,174 @@ +import app from '../app'; +import { GambitType, type GroupedGambitSuggestion, type KeyValueGambitSuggestion } from '../query/IGambit'; +import type IGambit from '../query/IGambit'; +import AutocompleteReader, { type AutocompleteCheck } from '../utils/AutocompleteReader'; +import Button from '../components/Button'; + +export default class GambitsAutocomplete { + protected query = ''; + + constructor( + public resource: string, + public jqueryInput: () => JQuery, + public onchange: (value: string) => void, + public afterSuggest: (value: string) => void + ) {} + + suggestions(query: string): JSX.Element[] { + const gambits = app.search.gambits.for(this.resource).filter((gambit) => gambit.enabled()); + this.query = query; + + // We group the boolean gambits together to produce an initial item of + // is:unread,sticky,locked, etc. + const groupedGambits: IGambit[] = gambits.filter((gambit) => gambit.type === GambitType.Grouped); + const keyValueGambits: IGambit[] = gambits.filter((gambit) => gambit.type !== GambitType.Grouped); + + const uniqueGroups: string[] = []; + for (const gambit of groupedGambits) { + if (uniqueGroups.includes(gambit.suggestion().group)) continue; + uniqueGroups.push(gambit.suggestion().group); + } + + const instancePerGroup: IGambit[] = []; + for (const group of uniqueGroups) { + instancePerGroup.push({ + type: GambitType.Grouped, + suggestion: () => ({ + group, + key: groupedGambits + .filter((gambit) => gambit.suggestion().group === group) + .map((gambit) => { + const key = gambit.suggestion().key; + + return key instanceof Array ? key.join(', ') : key; + }) + .join(', '), + }), + pattern: () => '', + filterKey: () => '', + toFilter: () => [], + fromFilter: () => '', + predicates: false, + enabled: () => true, + }); + } + + const autocompleteReader = new AutocompleteReader(null); + + const cursorPosition = this.jqueryInput().prop('selectionStart') || query.length; + const lastChunk = query.slice(0, cursorPosition); + const autocomplete = autocompleteReader.check(lastChunk, cursorPosition, /\S+$/); + + let typed = autocomplete?.typed || ''; + + // Negative gambits are a thing ;) + const negative = typed.startsWith('-'); + if (negative) { + typed = typed.slice(1); + } + + // if the query ends with 'is:' we will only list keys from that group. + if (typed.endsWith(':')) { + const gambitKey = typed.replace(/:$/, '') || null; + const gambitQuery = typed.split(':').pop() || ''; + + if (gambitKey) { + const specificGambitSuggestions = this.specificGambitSuggestions(gambitKey, gambitQuery, uniqueGroups, groupedGambits, autocomplete!); + + if (specificGambitSuggestions) { + return specificGambitSuggestions; + } + } + } + + // This is all the gambit suggestions. + return [...instancePerGroup, ...keyValueGambits] + .filter( + (gambit) => + !autocomplete || + new RegExp(typed).test( + gambit.type === GambitType.Grouped ? (gambit.suggestion() as GroupedGambitSuggestion).group : (gambit.suggestion().key as string) + ) + ) + .map((gambit) => { + const suggestion = gambit.suggestion(); + const key = gambit.type === GambitType.Grouped ? (suggestion as GroupedGambitSuggestion).group : (suggestion.key as string); + const hint = + gambit.type === GambitType.Grouped ? (suggestion as KeyValueGambitSuggestion).key : (suggestion as KeyValueGambitSuggestion).hint; + + return this.gambitSuggestion(key, hint, (negated: boolean | undefined) => + this.suggest(((!!negated && '-') || '') + key + ':', typed || '', (autocomplete?.relativeStart ?? cursorPosition) + Number(negative)) + ); + }); + } + + specificGambitSuggestions( + gambitKey: string, + gambitQuery: string, + uniqueGroups: string[], + groupedGambits: IGambit[], + autocomplete: AutocompleteCheck + ): JSX.Element[] | null { + if (uniqueGroups.includes(gambitKey)) { + return groupedGambits + .filter((gambit) => gambit.suggestion().group === gambitKey) + .flatMap((gambit): string[] => + gambit.suggestion().key instanceof Array ? (gambit.suggestion().key as string[]) : [gambit.suggestion().key as string] + ) + .filter((key) => !gambitQuery || key.toLowerCase().startsWith(gambitQuery)) + .map((gambit) => + this.gambitSuggestion(gambit, null, () => this.suggest(gambit, gambitQuery, autocomplete.relativeStart + autocomplete.typed.length)) + ); + } + + return null; + } + + gambitSuggestion(key: string, value: string | null, suggest: (negated?: boolean) => void): JSX.Element { + return ( +
  • + + + {!!value && ( + +
  • + ); + } + + suggest(text: string, fromTyped: string, start: number) { + const $input = this.jqueryInput(); + + const query = this.query; + const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length); + + this.onchange(replaced); + $input[0].focus(); + setTimeout(() => { + $input[0].setSelectionRange(start + text.length, start + text.length); + m.redraw(); + }, 50); + + this.afterSuggest(replaced); + } +} diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index 9a9f8a1222..e6b193398e 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -13,9 +13,8 @@ import InfoTile from '../../common/components/InfoTile'; import LoadingIndicator from '../../common/components/LoadingIndicator'; import type { SearchSource } from './Search'; import type IGambit from '../../common/query/IGambit'; -import { GambitType, type GroupedGambitSuggestion, type KeyValueGambitSuggestion } from '../../common/query/IGambit'; -import AutocompleteReader, { type AutocompleteCheck } from '../../common/utils/AutocompleteReader'; import ItemList from '../../common/utils/ItemList'; +import GambitsAutocomplete from '../../common/utils/GambitsAutocomplete'; export interface ISearchModalAttrs extends IFormModalAttrs { onchange: (value: string) => void; @@ -59,6 +58,8 @@ export default class SearchModal = {}; + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); @@ -79,6 +80,13 @@ export default class SearchModal this.inputElement(), + this.query, + (value: string) => this.search(value) + ); + const searchLabel = extractText(app.translator.trans('core.forum.search.placeholder')); return ( @@ -215,162 +223,7 @@ export default class SearchModal gambit.enabled()); - const query = this.query(); - - // We group the boolean gambits together to produce an initial item of - // is:unread,sticky,locked, etc. - const groupedGambits: IGambit[] = gambits.filter((gambit) => gambit.type === GambitType.Grouped); - const keyValueGambits: IGambit[] = gambits.filter((gambit) => gambit.type !== GambitType.Grouped); - - const uniqueGroups: string[] = []; - for (const gambit of groupedGambits) { - if (uniqueGroups.includes(gambit.suggestion().group)) continue; - uniqueGroups.push(gambit.suggestion().group); - } - - const instancePerGroup: IGambit[] = []; - for (const group of uniqueGroups) { - instancePerGroup.push({ - type: GambitType.Grouped, - suggestion: () => ({ - group, - key: groupedGambits - .filter((gambit) => gambit.suggestion().group === group) - .map((gambit) => { - const key = gambit.suggestion().key; - - return key instanceof Array ? key.join(', ') : key; - }) - .join(', '), - }), - pattern: () => '', - filterKey: () => '', - toFilter: () => [], - fromFilter: () => '', - predicates: false, - enabled: () => true, - }); - } - - const autocompleteReader = new AutocompleteReader(null); - - const $input = this.$('SearchModal-input') as JQuery; - const cursorPosition = $input.prop('selectionStart') || query.length; - const lastChunk = query.slice(0, cursorPosition); - const autocomplete = autocompleteReader.check(lastChunk, cursorPosition, /\S+$/); - - let typed = autocomplete?.typed || ''; - - // Negative gambits are a thing ;) - const negative = typed.startsWith('-'); - if (negative) { - typed = typed.slice(1); - } - - // if the query ends with 'is:' we will only list keys from that group. - if (typed.endsWith(':')) { - const gambitKey = typed.replace(/:$/, '') || null; - const gambitQuery = typed.split(':').pop() || ''; - - if (gambitKey) { - const specificGambitSuggestions = this.specificGambitSuggestions(gambitKey, gambitQuery, uniqueGroups, groupedGambits, autocomplete!); - - if (specificGambitSuggestions) { - return specificGambitSuggestions; - } - } - } - - // This is all the gambit suggestions. - return [...instancePerGroup, ...keyValueGambits] - .filter( - (gambit) => - !autocomplete || - new RegExp(typed).test( - gambit.type === GambitType.Grouped ? (gambit.suggestion() as GroupedGambitSuggestion).group : (gambit.suggestion().key as string) - ) - ) - .map((gambit) => { - const suggestion = gambit.suggestion(); - const key = gambit.type === GambitType.Grouped ? (suggestion as GroupedGambitSuggestion).group : (suggestion.key as string); - const hint = - gambit.type === GambitType.Grouped ? (suggestion as KeyValueGambitSuggestion).key : (suggestion as KeyValueGambitSuggestion).hint; - - return this.gambitSuggestion(key, hint, (negated: boolean | undefined) => - this.suggest(((!!negated && '-') || '') + key + ':', typed || '', (autocomplete?.relativeStart ?? cursorPosition) + Number(negative)) - ); - }); - } - - specificGambitSuggestions( - gambitKey: string, - gambitQuery: string, - uniqueGroups: string[], - groupedGambits: IGambit[], - autocomplete: AutocompleteCheck - ): JSX.Element[] | null { - if (uniqueGroups.includes(gambitKey)) { - return groupedGambits - .filter((gambit) => gambit.suggestion().group === gambitKey) - .flatMap((gambit): string[] => - gambit.suggestion().key instanceof Array ? (gambit.suggestion().key as string[]) : [gambit.suggestion().key as string] - ) - .filter((key) => !gambitQuery || key.toLowerCase().startsWith(gambitQuery)) - .map((gambit) => - this.gambitSuggestion(gambit, null, () => this.suggest(gambit, gambitQuery, autocomplete.relativeStart + autocomplete.typed.length)) - ); - } - - return null; - } - - gambitSuggestion(key: string, value: string | null, suggest: (negated?: boolean) => void): JSX.Element { - return ( -
  • - - - {!!value && ( - -
  • - ); - } - - suggest(text: string, fromTyped: string, start: number) { - const $input = this.inputElement() as JQuery; - - const query = this.query(); - const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length); - - this.query(replaced); - $input[0].focus(); - setTimeout(() => { - $input[0].setSelectionRange(start + text.length, start + text.length); - m.redraw(); - }, 50); - - this.search(replaced); + return this.gambitsAutocomplete[this.activeSource().resource].suggestions(this.query()); } /** @@ -576,7 +429,7 @@ export default class SearchModal { - return this.$('.SearchModal-input'); + inputElement(): JQuery { + return this.$('.SearchModal-input') as JQuery; } } diff --git a/framework/core/less/common/Dropdown.less b/framework/core/less/common/Dropdown.less index a47a258aeb..aa73d90249 100644 --- a/framework/core/less/common/Dropdown.less +++ b/framework/core/less/common/Dropdown.less @@ -230,3 +230,45 @@ } } } + +.AutocompleteDropdown { + position: relative; +} + +.GambitsAutocomplete { + &-gambit { + display: flex; + align-items: center; + + > button { + flex-grow: 1; + cursor: pointer; + gap: 4px; + display: flex; + align-items: center; + padding: 8px 15px; + margin: -8px 0 -8px -15px; + } + &-key { + font-weight: bold; + } + &-value { + color: var(--control-color); + } + &-actions { + flex-shrink: 0; + display: flex; + color: var(--control-color); + visibility: hidden; + margin-inline-end: -14px; + + .Button { + margin: -8px 0; + } + } + } +} + +li.active .GambitsAutocomplete-gambit-actions { + visibility: visible; +} diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index 56267a6914..ba423e72d8 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -114,48 +114,11 @@ border-radius: var(--border-radius); } - &-gambit { - display: flex; - align-items: center; - - > button { - flex-grow: 1; - cursor: pointer; - gap: 4px; - display: flex; - align-items: center; - padding: 8px 15px; - margin: -8px 0 -8px -15px; - } - &-key { - font-weight: bold; - } - &-value { - color: var(--control-color); - } - &-actions { - flex-shrink: 0; - display: flex; - color: var(--control-color); - visibility: hidden; - margin-inline-end: -14px; - - .Button { - margin: -8px 0; - } - } - } - .Input { z-index: 0; } } -li.active .SearchModal-gambit-actions { - visibility: visible; -} - - .SearchModal-visual-wrapper { position: absolute; inset: 0; From 8b91e6e0f703bac57bb415fb190a9e74e444f5eb Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 1 Dec 2023 16:14:02 +0100 Subject: [PATCH 18/19] chore: format --- framework/core/js/src/admin/components/AdminPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/framework/core/js/src/admin/components/AdminPage.tsx b/framework/core/js/src/admin/components/AdminPage.tsx index 03c677ac6f..5c0368414b 100644 --- a/framework/core/js/src/admin/components/AdminPage.tsx +++ b/framework/core/js/src/admin/components/AdminPage.tsx @@ -88,14 +88,14 @@ const ImageUploadSettingType = 'image-upload' as const; * Valid options for the setting component builder to generate a Switch. */ export interface SwitchSettingComponentOptions extends CommonSettingsItemOptions { - type: (typeof BooleanSettingTypes)[number]; + type: typeof BooleanSettingTypes[number]; } /** * Valid options for the setting component builder to generate a Select dropdown. */ export interface SelectSettingComponentOptions extends CommonSettingsItemOptions { - type: (typeof SelectSettingTypes)[number]; + type: typeof SelectSettingTypes[number]; /** * Map of values to their labels */ @@ -107,7 +107,7 @@ export interface SelectSettingComponentOptions extends CommonSettingsItemOptions * Valid options for the setting component builder to generate a Textarea. */ export interface TextareaSettingComponentOptions extends CommonSettingsItemOptions { - type: (typeof TextareaSettingTypes)[number]; + type: typeof TextareaSettingTypes[number]; } /** From c5b463c29e67ec13ca2b60672f13e6c1f03a6299 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 1 Dec 2023 16:33:20 +0100 Subject: [PATCH 19/19] fix: tag filter --- extensions/tags/src/Search/Filter/TagFilter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/tags/src/Search/Filter/TagFilter.php b/extensions/tags/src/Search/Filter/TagFilter.php index 2446cf5025..5c82c6bfde 100644 --- a/extensions/tags/src/Search/Filter/TagFilter.php +++ b/extensions/tags/src/Search/Filter/TagFilter.php @@ -43,6 +43,8 @@ public function filter(SearchState $state, string|array $value, bool $negate): v protected function constrain(Builder $query, string|array $rawSlugs, bool $negate, User $actor): void { + $rawSlugs = (array) $rawSlugs; + $inputSlugs = $this->asStringArray($rawSlugs); foreach ($inputSlugs as $orSlugs) {