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:
@@ -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 {
|
||||
|
@@ -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">
|
||||
|
@@ -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')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
73
framework/core/js/src/admin/components/AdvancedPage.tsx
Normal file
73
framework/core/js/src/admin/components/AdvancedPage.tsx
Normal 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')),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
@@ -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')),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
35
framework/core/js/src/admin/components/FormSectionGroup.tsx
Normal file
35
framework/core/js/src/admin/components/FormSectionGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
72
framework/core/js/src/common/GambitManager.ts
Normal file
72
framework/core/js/src/common/GambitManager.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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',
|
||||
|
24
framework/core/js/src/common/extenders/Search.ts
Normal file
24
framework/core/js/src/common/extenders/Search.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
6
framework/core/js/src/common/query/IGambit.ts
Normal file
6
framework/core/js/src/common/query/IGambit.ts
Normal 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;
|
||||
}
|
@@ -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}`;
|
||||
}
|
||||
}
|
@@ -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}`;
|
||||
}
|
||||
}
|
@@ -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`;
|
||||
}
|
||||
}
|
@@ -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`;
|
||||
}
|
||||
}
|
23
framework/core/js/src/common/query/users/EmailGambit.ts
Normal file
23
framework/core/js/src/common/query/users/EmailGambit.ts
Normal 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}`;
|
||||
}
|
||||
}
|
23
framework/core/js/src/common/query/users/GroupGambit.ts
Normal file
23
framework/core/js/src/common/query/users/GroupGambit.ts
Normal 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}`;
|
||||
}
|
||||
}
|
@@ -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>,
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
34
framework/core/js/tests/unit/common/GambitManager.test.ts
Normal file
34
framework/core/js/tests/unit/common/GambitManager.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user