1
0
mirror of https://github.com/flarum/core.git synced 2025-08-06 08:27:42 +02:00

feat: revamp search (#3893)

* refactor: move gambits to frontend (#3885)
* refactor: move gambits to frontend
* test: GambitManager
* refactor: merge filterer and searcher concepts (#3892)
* chore: drop remaining backend regex gambits
* refactor: merge filterer & searcher concept
* refactor: adapt extenders
* refactor: no longer need to push gambits to `q`
* refactor: filters to gambits
* refactor: drop shred `Query` namespace
* chore: cleanup
* chore: leftover gambit references on the backend (#3894)
* chore: leftover gambit references on the backend
* chore: namespace
* feat: search driver backend extension API (#3902)
* feat: first iteration of search drivers
* feat: indexer API & tweaks
* feat: changes after POC driver
* fix: properly fire custom observables
* chore: remove debugging code
* fix: phpstan
* fix: custom eloquent events
* chore: drop POC usage
* test: indexer extender API
* fix: extension searcher fails without filters
* fix: phpstan
* fix: frontend created gambit
* feat: advanced page and localized driver settings (#3905)
* feat: allow getting total search results and replacing filters (#3906)
* feat: allow accessing total search results
* feat: allow replacing filters
* chore: phpstan
This commit is contained in:
Sami Mazouz
2023-11-11 19:43:09 +01:00
committed by GitHub
parent 9e04b010d8
commit 4b126d9f4c
161 changed files with 2734 additions and 2197 deletions

View File

@@ -40,7 +40,9 @@ export interface AdminApplicationData extends ApplicationData {
modelStatistics: Record<string, { total: number }>;
displayNameDrivers: string[];
slugDrivers: Record<string, string[]>;
searchDrivers: Record<string, string[]>;
permissions: Record<string, string[]>;
advancedPageEmpty: boolean;
}
export default class AdminApplication extends Application {

View File

@@ -110,6 +110,16 @@ export default class AdminNav extends Component {
50
);
if (app.data.settings.show_advanced_settings && !app.data.advancedPageEmpty) {
items.add(
'advanced',
<LinkButton href={app.route('advanced')} icon="fas fa-cog" title={app.translator.trans('core.admin.nav.advanced_title')}>
{app.translator.trans('core.admin.nav.advanced_button')}
</LinkButton>,
40
);
}
items.add(
'search',
<div className="Search-input">

View File

@@ -14,6 +14,7 @@ import ColorPreviewInput from '../../common/components/ColorPreviewInput';
import ItemList from '../../common/utils/ItemList';
import type { IUploadImageButtonAttrs } from './UploadImageButton';
import UploadImageButton from './UploadImageButton';
import extractText from '../../common/utils/extractText';
export interface AdminHeaderOptions {
title: Mithril.Children;
@@ -410,4 +411,12 @@ export default abstract class AdminPage<CustomAttrs extends IPageAttrs = IPageAt
return saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
modelLocale(): Record<string, string> {
return {
'Flarum\\Discussion\\Discussion': extractText(app.translator.trans('core.admin.models.discussions')),
'Flarum\\User\\User': extractText(app.translator.trans('core.admin.models.users')),
'Flarum\\Post\\Post': extractText(app.translator.trans('core.admin.models.posts')),
};
}
}

View File

@@ -0,0 +1,73 @@
import app from '../../admin/app';
import AdminPage from './AdminPage';
import type { IPageAttrs } from '../../common/components/Page';
import type Mithril from 'mithril';
import Form from '../../common/components/Form';
import extractText from '../../common/utils/extractText';
import FormSectionGroup, { FormSection } from './FormSectionGroup';
export default class AdvancedPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
searchDriverOptions: Record<string, Record<string, string>> = {};
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
const locale = this.driverLocale();
Object.keys(app.data.searchDrivers).forEach((model) => {
this.searchDriverOptions[model] = {};
app.data.searchDrivers[model].forEach((option) => {
this.searchDriverOptions[model][option] = locale.search[option] || option;
});
});
}
headerInfo() {
return {
className: 'AdvancedPage',
icon: 'fas fa-cog',
title: app.translator.trans('core.admin.advanced.title'),
description: app.translator.trans('core.admin.advanced.description'),
};
}
content() {
return [
<Form className="AdvancedPage-container">
<FormSectionGroup>
<FormSection label={app.translator.trans('core.admin.advanced.search.section_label')}>
<Form>
{Object.keys(this.searchDriverOptions).map((model) => {
const options = this.searchDriverOptions[model];
const modelLocale = this.modelLocale()[model] || model;
if (Object.keys(options).length > 1) {
return this.buildSettingComponent({
type: 'select',
setting: `search_driver_${model}`,
options,
label: app.translator.trans('core.admin.advanced.search.driver_heading', { model: modelLocale }),
help: app.translator.trans('core.admin.advanced.search.driver_text', { model: modelLocale }),
});
}
return null;
})}
</Form>
</FormSection>
</FormSectionGroup>
<div className="Form-group Form-controls">{this.submitButton()}</div>
</Form>,
];
}
driverLocale(): Record<string, Record<string, string>> {
return {
search: {
default: extractText(app.translator.trans('core.admin.advanced.search.driver_options.default')),
},
};
}
}

View File

@@ -5,8 +5,13 @@ import AdminPage from './AdminPage';
import type { IPageAttrs } from '../../common/components/Page';
import type Mithril from 'mithril';
import Form from '../../common/components/Form';
import extractText from '../../common/utils/extractText';
export type HomePageItem = { path: string; label: Mithril.Children };
export type DriverLocale = {
display_name: Record<string, string>;
slug: Record<string, Record<string, string>>;
};
export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
localeOptions: Record<string, string> = {};
@@ -20,15 +25,17 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
this.localeOptions[i] = `${app.data.locales[i]} (${i})`;
});
const driverLocale = this.driverLocale();
app.data.displayNameDrivers.forEach((identifier) => {
this.displayNameOptions[identifier] = identifier;
this.displayNameOptions[identifier] = driverLocale.display_name[identifier] || identifier;
});
Object.keys(app.data.slugDrivers).forEach((model) => {
this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = option;
this.slugDriverOptions[model][option] = (driverLocale.slug[model] && driverLocale.slug[model][option]) || option;
});
});
}
@@ -108,14 +115,15 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
const modelLocale = this.modelLocale()[model] || model;
if (Object.keys(options).length > 1) {
return this.buildSettingComponent({
type: 'select',
setting: `slug_driver_${model}`,
options,
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model: modelLocale }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model: modelLocale }),
});
}
@@ -141,4 +149,22 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
return items;
}
driverLocale(): DriverLocale {
return {
display_name: {
username: extractText(app.translator.trans('core.admin.basics.display_name_driver_options.username')),
},
slug: {
'Flarum\\Discussion\\Discussion': {
default: extractText(app.translator.trans('core.admin.basics.slug_driver_options.discussions.default')),
utf8: extractText(app.translator.trans('core.admin.basics.slug_driver_options.discussions.utf8')),
},
'Flarum\\User\\User': {
default: extractText(app.translator.trans('core.admin.basics.slug_driver_options.users.default')),
id: extractText(app.translator.trans('core.admin.basics.slug_driver_options.users.id')),
},
},
};
}
}

View File

@@ -0,0 +1,35 @@
import Component from '../../common/Component';
import type { ComponentAttrs } from '../../common/Component';
import Mithril from 'mithril';
import classList from '../../common/utils/classList';
export interface IFormSectionGroupAttrs extends ComponentAttrs {}
export default class FormSectionGroup<CustomAttrs extends IFormSectionGroupAttrs = IFormSectionGroupAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
const { className, ...attrs } = this.attrs;
return (
<div className={classList('FormSectionGroup', className)} {...attrs}>
{vnode.children}
</div>
);
}
}
export interface IFormSectionAttrs extends ComponentAttrs {
label: any;
}
export class FormSection<CustomAttrs extends IFormSectionAttrs = IFormSectionAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
const { className, ...attrs } = this.attrs;
return (
<div className={classList('FormSection', className)} {...attrs}>
<label>{this.attrs.label}</label>
<div className="FormSection-body">{vnode.children}</div>
</div>
);
}
}

View File

@@ -6,6 +6,7 @@ import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import LoadingModal from './LoadingModal';
import LinkButton from '../../common/components/LinkButton';
import saveSettings from '../utils/saveSettings.js';
export default class StatusWidget extends DashboardWidget {
className() {
@@ -71,6 +72,25 @@ export default class StatusWidget extends DashboardWidget {
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
);
if (!app.data.advancedPageEmpty) {
items.add(
'toggleAdvancedPage',
<Button
onclick={() => {
saveSettings({
show_advanced_settings: !app.data.settings.show_advanced_settings,
});
if (app.data.settings.show_advanced_settings) {
m.route.set(app.route('advanced'));
}
}}
>
{app.translator.trans('core.admin.dashboard.toggle_advanced_page_button')}
</Button>
);
}
return items;
}

View File

@@ -7,6 +7,7 @@ import MailPage from './components/MailPage';
import UserListPage from './components/UserListPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
import AdvancedPage from './components/AdvancedPage';
/**
* Helper functions to generate URLs to admin pages.
@@ -24,6 +25,7 @@ export default function (app: AdminApplication) {
appearance: { path: '/appearance', component: AppearancePage },
mail: { path: '/mail', component: MailPage },
users: { path: '/users', component: UserListPage },
advanced: { path: '/advanced', component: AdvancedPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
};
}

View File

@@ -0,0 +1,72 @@
import IGambit from './query/IGambit';
import AuthorGambit from './query/discussions/AuthorGambit';
import CreatedGambit from './query/discussions/CreatedGambit';
import HiddenGambit from './query/discussions/HiddenGambit';
import UnreadGambit from './query/discussions/UnreadGambit';
import EmailGambit from './query/users/EmailGambit';
import GroupGambit from './query/users/GroupGambit';
/**
* The gambit registry. A map of resource types to gambit classes that
* should be used to filter resources of that type. Gambits are automatically
* converted to API filters when requesting resources. Gambits must be applied
* on a filter object that has a `q` property containing the search query.
*/
export default class GambitManager {
gambits: Record<string, Array<new () => IGambit>> = {
discussions: [AuthorGambit, CreatedGambit, HiddenGambit, UnreadGambit],
users: [EmailGambit, GroupGambit],
};
public apply(type: string, filter: Record<string, any>): Record<string, any> {
const gambits = this.gambits[type] || [];
if (gambits.length === 0) return filter;
const bits: string[] = filter.q.split(' ');
for (const gambitClass of gambits) {
const gambit = new gambitClass();
for (const bit of bits) {
const pattern = `^(-?)${gambit.pattern()}$`;
let matches = bit.match(pattern);
if (matches) {
const negate = matches[1] === '-';
matches.splice(1, 1);
Object.assign(filter, gambit.toFilter(matches, negate));
filter.q = filter.q.replace(bit, '');
}
}
}
filter.q = filter.q.trim().replace(/\s+/g, ' ');
return filter;
}
public from(type: string, q: string, filter: Record<string, any>): string {
const gambits = this.gambits[type] || [];
if (gambits.length === 0) return q;
Object.keys(filter).forEach((key) => {
for (const gambitClass of gambits) {
const gambit = new gambitClass();
const negate = key[0] === '-';
if (negate) key = key.substring(1);
if (gambit.filterKey() !== key) continue;
q += ` ${gambit.fromFilter(filter[key], negate)}`;
}
});
return q;
}
}

View File

@@ -1,6 +1,7 @@
import app from '../common/app';
import { FlarumRequestOptions } from './Application';
import Model, { ModelData, SavedModelData } from './Model';
import GambitManager from './GambitManager';
export interface MetaInformation {
[key: string]: any;
@@ -20,7 +21,7 @@ export interface ApiQueryParamsPlural {
| {
q: string;
}
| Record<string, string>;
| Record<string, any>;
page?: {
near?: number;
offset?: number;
@@ -88,6 +89,12 @@ export default class Store {
*/
models: Record<string, { new (): Model }>;
/**
* The gambit manager that will convert search query gambits
* into API filters.
*/
gambits = new GambitManager();
constructor(models: Record<string, { new (): Model }>) {
this.models = models;
}
@@ -178,6 +185,10 @@ export default class Store {
url += '/' + idOrParams;
}
if ('filter' in params && params?.filter?.q) {
params.filter = this.gambits.apply(type, params.filter);
}
return app
.request<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>({
method: 'GET',

View File

@@ -0,0 +1,24 @@
import type IExtender from './IExtender';
import type { IExtensionModule } from './IExtender';
import type Application from '../Application';
import IGambit from '../query/IGambit';
export default class Search implements IExtender {
protected gambits: Record<string, Array<new () => IGambit>> = {};
public gambit(modelType: string, gambit: new () => IGambit): this {
this.gambits[modelType] = this.gambits[modelType] || [];
this.gambits[modelType].push(gambit);
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);
}
}
}
}

View File

@@ -2,12 +2,14 @@ import Model from './Model';
import PostTypes from './PostTypes';
import Routes from './Routes';
import Store from './Store';
import Search from './Search';
const extenders = {
Model,
PostTypes,
Routes,
Store,
Search,
};
export default extenders;

View File

@@ -0,0 +1,6 @@
export default interface IGambit {
pattern(): string;
toFilter(matches: string[], negate: boolean): Record<string, any>;
filterKey(): string;
fromFilter(value: string, negate: boolean): string;
}

View File

@@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class AuthorGambit implements IGambit {
public pattern(): string {
return 'author:(.+)';
}
public toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'author';
return {
[key]: matches[1].split(','),
};
}
filterKey(): string {
return 'author';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}author:${value}`;
}
}

View File

@@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class CreatedGambit implements IGambit {
pattern(): string {
return 'created:(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)';
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'created';
return {
[key]: matches[1],
};
}
filterKey(): string {
return 'created';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}created:${value}`;
}
}

View File

@@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class HiddenGambit implements IGambit {
public pattern(): string {
return 'is:hidden';
}
public toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'hidden';
return {
[key]: true,
};
}
filterKey(): string {
return 'hidden';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:hidden`;
}
}

View File

@@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class UnreadGambit implements IGambit {
pattern(): string {
return 'is:unread';
}
toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'unread';
return {
[key]: true,
};
}
filterKey(): string {
return 'unread';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:unread`;
}
}

View File

@@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class EmailGambit implements IGambit {
pattern(): string {
return 'email:(.+)';
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'email';
return {
[key]: matches[1],
};
}
filterKey(): string {
return 'email';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}email:${value}`;
}
}

View File

@@ -0,0 +1,23 @@
import IGambit from '../IGambit';
export default class GroupGambit implements IGambit {
pattern(): string {
return 'group:(.+)';
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'group';
return {
[key]: matches[1].split(','),
};
}
filterKey(): string {
return 'group';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}group:${value}`;
}
}

View File

@@ -48,10 +48,15 @@ export default class DiscussionsSearchSource implements SearchSource {
);
}) as Array<Mithril.Vnode>;
const filter = app.store.gambits.apply('discussions', { q: query });
const q = filter.q || null;
delete filter.q;
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li>
<LinkButton icon="fas fa-search" href={app.route('index', { q: query })}>
<LinkButton icon="fas fa-search" href={app.route('index', { q, filter })}>
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
</LinkButton>
</li>,

View File

@@ -36,7 +36,14 @@ export default class GlobalSearchState extends SearchState {
* @inheritdoc
*/
getInitialSearch(): string {
return this.currPageProvidesSearch() ? this.params().q : '';
return this.currPageProvidesSearch() ? this.searchToQuery() : '';
}
private searchToQuery(): string {
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();
}
/**
@@ -57,7 +64,7 @@ export default class GlobalSearchState extends SearchState {
* 'x' is clicked in the search box in the header.
*/
protected clearInitialSearch() {
const { q, ...params } = this.params();
const { q, filter, ...params } = this.params();
setRouteWithForcedRefresh(app.route(app.current.get('routeName'), params));
}
@@ -71,6 +78,9 @@ export default class GlobalSearchState extends SearchState {
return {
sort: m.route.param('sort'),
q: m.route.param('q'),
// Objects must be copied, otherwise they are passed by reference.
// Which could end up undesirably modifying the mithril route params.
filter: Object.assign({}, m.route.param('filter')),
};
}
@@ -80,8 +90,6 @@ export default class GlobalSearchState extends SearchState {
params(): SearchParams {
const params = this.stickyParams();
params.filter = m.route.param('filter');
return params;
}

View File

@@ -0,0 +1,34 @@
import GambitManager from '../../../src/common/GambitManager';
const gambits = new GambitManager();
test('gambits are converted to filters', function () {
expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07 is:hidden author:behz' })).toStrictEqual({
q: 'lorem',
created: '2023-07-07',
hidden: true,
author: ['behz'],
});
});
test('gambits are negated when prefixed with a dash', function () {
expect(gambits.apply('discussions', { q: 'lorem -created:2023-07-07 -is:hidden -author:behz' })).toStrictEqual({
q: 'lorem',
'-created': '2023-07-07',
'-hidden': true,
'-author': ['behz'],
});
});
test('gambits are only applied for the correct resource type', function () {
expect(gambits.apply('users', { q: 'lorem created:2023-07-07 is:hidden author:behz email:behz@machine.local' })).toStrictEqual({
q: 'lorem created:2023-07-07 is:hidden author:behz',
email: 'behz@machine.local',
});
expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07..2023-10-18 is:hidden -author:behz email:behz@machine.local' })).toStrictEqual({
q: 'lorem email:behz@machine.local',
created: '2023-07-07..2023-10-18',
hidden: true,
'-author': ['behz'],
});
});