mirror of
https://github.com/discourse/discourse.git
synced 2025-06-01 06:17:38 +08:00
FEATURE: Initial version of experimental admin search (#31299)
This feature allows admins to find what they are looking for in the admin interface via a search modal. This replaces the admin sidebar filter as the focus of the Ctrl+/ command, but the sidebar filter can also still be used. Perhaps at some point we may remove it or change the shortcut. The search modal presents the following data for filtering: * A list of all admin pages, the same as the sidebar, except also showing "third level" pages like "Email > Skipped" * All site settings * Themes * Components * Reports Admins can also filter which types of items are shown in the modal, for example hiding Settings if they know they are looking for a Page. In this PR, I also have the following fixes: * Site setting filters now clear when moving between filtered site setting pages, previously it was super sticky from Ember * Many translations were moved around, instead of being in various namespaces for the sidebar links and the admin page titles and descriptions, now everything is under `admin.config` namespace, this makes it way easier to reuse this text for pages, search, and sidebar, and if you change it in one place then it is changed everywhere. --------- Co-authored-by: Ella <ella.estigoy@gmail.com>
This commit is contained in:
@ -0,0 +1,320 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import Service, { service } from "@ember/service";
|
||||
import { adminRouteValid } from "discourse/lib/admin-utilities";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import escapeRegExp from "discourse/lib/escape-regexp";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import PreloadStore from "discourse/lib/preload-store";
|
||||
import { ADMIN_NAV_MAP } from "discourse/lib/sidebar/admin-nav-map";
|
||||
import { humanizedSettingName } from "discourse/lib/site-settings-utils";
|
||||
import I18n, { i18n } from "discourse-i18n";
|
||||
|
||||
// TODO (martin) Move this to javascript.rake constants, use on server too
|
||||
export const RESULT_TYPES = ["page", "setting", "theme", "component", "report"];
|
||||
const SEPARATOR = ">";
|
||||
const MIN_FILTER_LENGTH = 2;
|
||||
const MAX_TYPE_RESULT_COUNT_LOW = 15;
|
||||
const MAX_TYPE_RESULT_COUNT_HIGH = 50;
|
||||
|
||||
export default class AdminSearchDataSource extends Service {
|
||||
@service router;
|
||||
@service siteSettings;
|
||||
|
||||
plugins = {};
|
||||
pageMapItems = [];
|
||||
settingMapItems = [];
|
||||
themeMapItems = [];
|
||||
componentMapItems = [];
|
||||
reportMapItems = [];
|
||||
settingPageMap = {
|
||||
categories: {},
|
||||
areas: {},
|
||||
};
|
||||
@tracked _mapCached = false;
|
||||
|
||||
get isLoaded() {
|
||||
return this._mapCached;
|
||||
}
|
||||
|
||||
async buildMap() {
|
||||
if (this._mapCached) {
|
||||
return;
|
||||
}
|
||||
|
||||
ADMIN_NAV_MAP.forEach((mapItem) => {
|
||||
mapItem.links.forEach((link) => {
|
||||
let parentLabel = this.#addPageLink(mapItem, link);
|
||||
|
||||
link.links?.forEach((subLink) => {
|
||||
this.#addPageLink(mapItem, subLink, parentLabel);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// TODO (martin) Handle plugin enabling/disabling via MessageBus for this
|
||||
// and the setting list?
|
||||
(PreloadStore.get("visiblePlugins") || {}).forEach((plugin) => {
|
||||
if (
|
||||
plugin.admin_route &&
|
||||
plugin.enabled &&
|
||||
adminRouteValid(this.router, plugin.admin_route)
|
||||
) {
|
||||
this.plugins[plugin.name] = plugin;
|
||||
}
|
||||
});
|
||||
|
||||
const allItems = await ajax("/admin/search/all.json");
|
||||
this.#processSettings(allItems.settings);
|
||||
this.#processThemesAndComponents(allItems.themes_and_components);
|
||||
|
||||
// TODO (martin) Move this to all.json after refactoring reports controller
|
||||
// into a service.
|
||||
const reportItems = await ajax("/admin/reports.json");
|
||||
this.#processReports(reportItems.reports);
|
||||
|
||||
this._mapCached = true;
|
||||
}
|
||||
|
||||
search(filter, opts = {}) {
|
||||
if (filter.length < MIN_FILTER_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
opts.types = opts.types || RESULT_TYPES;
|
||||
|
||||
const filteredResults = [];
|
||||
const escapedFilterRegExp = escapeRegExp(filter.toLowerCase());
|
||||
|
||||
// Pointless to render heaps of settings if the filter is quite low.
|
||||
const perTypeLimit =
|
||||
filter.length < MIN_FILTER_LENGTH + 1
|
||||
? MAX_TYPE_RESULT_COUNT_LOW
|
||||
: MAX_TYPE_RESULT_COUNT_HIGH;
|
||||
|
||||
opts.types.forEach((type) => {
|
||||
let typeItemCount = 0;
|
||||
this[`${type}MapItems`].forEach((mapItem) => {
|
||||
// TODO (martin) There is likely a much better way of doing this matching
|
||||
// that will support fuzzy searches, for now let's go with the most basic thing.
|
||||
if (
|
||||
mapItem.keywords.match(escapedFilterRegExp) &&
|
||||
typeItemCount <= perTypeLimit
|
||||
) {
|
||||
filteredResults.push(mapItem);
|
||||
typeItemCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return filteredResults;
|
||||
}
|
||||
|
||||
#addPageLink(mapItem, link, parentLabel = "") {
|
||||
let url;
|
||||
if (link.routeModels) {
|
||||
url = this.router.urlFor(link.route, ...link.routeModels);
|
||||
} else {
|
||||
url = this.router.urlFor(link.route);
|
||||
}
|
||||
|
||||
const mapItemLabel = this.#labelOrText(mapItem);
|
||||
const linkLabel = this.#labelOrText(link);
|
||||
|
||||
let label;
|
||||
if (parentLabel) {
|
||||
label = mapItemLabel;
|
||||
if (mapItemLabel) {
|
||||
label += ` ${SEPARATOR} `;
|
||||
}
|
||||
label += `${parentLabel} ${SEPARATOR} ${linkLabel}`;
|
||||
} else {
|
||||
label = mapItemLabel + (mapItemLabel ? ` ${SEPARATOR} ` : "") + linkLabel;
|
||||
}
|
||||
|
||||
if (link.settings_area) {
|
||||
this.settingPageMap.areas[link.settings_area] = link.multi_tabbed
|
||||
? `${url}/settings`
|
||||
: url;
|
||||
}
|
||||
|
||||
if (link.settings_category) {
|
||||
this.settingPageMap.categories[link.settings_category] = link.multi_tabbed
|
||||
? `${url}/settings`
|
||||
: url;
|
||||
}
|
||||
|
||||
const linkKeywords = link.keywords
|
||||
? i18n(link.keywords).toLowerCase().replaceAll("|", " ")
|
||||
: "";
|
||||
const linkDescription = link.description
|
||||
? link.description.includes(" ")
|
||||
? link.description
|
||||
: i18n(link.description)
|
||||
: "";
|
||||
|
||||
this.pageMapItems.push({
|
||||
label,
|
||||
url,
|
||||
keywords: this.#buildKeywords(
|
||||
linkKeywords,
|
||||
url,
|
||||
label.replace(SEPARATOR, "").toLowerCase().replace(/ +/g, " "),
|
||||
linkDescription
|
||||
),
|
||||
type: "page",
|
||||
icon: link.icon,
|
||||
description: linkDescription,
|
||||
});
|
||||
|
||||
return linkLabel;
|
||||
}
|
||||
|
||||
#processSettings(settings) {
|
||||
const settingPluginNames = {};
|
||||
|
||||
settings.forEach((setting) => {
|
||||
let plugin;
|
||||
|
||||
let rootLabel;
|
||||
if (setting.plugin) {
|
||||
if (!settingPluginNames[setting.plugin]) {
|
||||
settingPluginNames[setting.plugin] = setting.plugin.replaceAll(
|
||||
"_",
|
||||
"-"
|
||||
);
|
||||
}
|
||||
|
||||
plugin = this.plugins[settingPluginNames[setting.plugin]];
|
||||
|
||||
if (plugin) {
|
||||
rootLabel = plugin.admin_route?.label
|
||||
? i18n(plugin.admin_route?.label)
|
||||
: i18n("admin.plugins.title");
|
||||
} else {
|
||||
rootLabel = i18n("admin.plugins.title");
|
||||
}
|
||||
} else if (setting.primary_area) {
|
||||
rootLabel =
|
||||
I18n.lookup(`admin.config.${setting.primary_area}.title`) ||
|
||||
i18n(`admin.site_settings.categories.${setting.category}`);
|
||||
} else {
|
||||
rootLabel = i18n(`admin.site_settings.categories.${setting.category}`);
|
||||
}
|
||||
|
||||
const label = `${rootLabel} ${SEPARATOR} ${humanizedSettingName(
|
||||
setting.setting
|
||||
)}`;
|
||||
|
||||
// TODO (martin) These URLs will need to change eventually to anchors
|
||||
// to focus on a specific element on the page, for now though the filter is fine.
|
||||
let url;
|
||||
if (setting.plugin) {
|
||||
if (plugin) {
|
||||
url = plugin.admin_route.use_new_show_route
|
||||
? this.router.urlFor(
|
||||
`adminPlugins.show.settings`,
|
||||
plugin.admin_route.location,
|
||||
{ queryParams: { filter: setting.setting } }
|
||||
)
|
||||
: this.router.urlFor(`adminPlugins.${plugin.admin_route.location}`);
|
||||
} else {
|
||||
url = getURL(
|
||||
`/admin/site_settings/category/all_results?filter=${setting.setting}`
|
||||
);
|
||||
}
|
||||
} else if (this.settingPageMap.areas[setting.primary_area]) {
|
||||
url =
|
||||
this.settingPageMap.areas[setting.primary_area] +
|
||||
`?filter=${setting.setting}`;
|
||||
} else if (this.settingPageMap.categories[setting.category]) {
|
||||
url =
|
||||
this.settingPageMap.categories[setting.category] +
|
||||
`?filter=${setting.setting}`;
|
||||
} else {
|
||||
url = getURL(
|
||||
`/admin/site_settings/category/all_results?filter=${setting.setting}`
|
||||
);
|
||||
}
|
||||
|
||||
this.settingMapItems.push({
|
||||
label,
|
||||
description: setting.description,
|
||||
url,
|
||||
keywords: this.#buildKeywords(
|
||||
setting.setting,
|
||||
humanizedSettingName(setting.setting),
|
||||
setting.description,
|
||||
setting.keywords,
|
||||
rootLabel
|
||||
),
|
||||
type: "setting",
|
||||
icon: "gear",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#processThemesAndComponents(themesAndComponents) {
|
||||
themesAndComponents.forEach((themeOrComponent) => {
|
||||
if (themeOrComponent.component) {
|
||||
this.componentMapItems.push({
|
||||
label: themeOrComponent.name,
|
||||
description: themeOrComponent.description,
|
||||
url: getURL(`/admin/customize/components/${themeOrComponent.id}`),
|
||||
keywords: this.#buildKeywords(
|
||||
"component",
|
||||
themeOrComponent.description,
|
||||
themeOrComponent.name
|
||||
),
|
||||
type: "component",
|
||||
icon: "puzzle-piece",
|
||||
});
|
||||
} else {
|
||||
this.themeMapItems.push({
|
||||
label: themeOrComponent.name,
|
||||
description: themeOrComponent.description,
|
||||
url: getURL(`/admin/customize/themes/${themeOrComponent.id}`),
|
||||
keywords: this.#buildKeywords(
|
||||
"theme",
|
||||
themeOrComponent.description,
|
||||
themeOrComponent.name
|
||||
),
|
||||
type: "theme",
|
||||
icon: "paintbrush",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#processReports(reports) {
|
||||
reports.forEach((report) => {
|
||||
this.reportMapItems.push({
|
||||
label: report.title,
|
||||
description: report.description,
|
||||
url: getURL(`/admin/reports/${report.type}`),
|
||||
icon: "chart-bar",
|
||||
keywords: this.#buildKeywords(
|
||||
report.title,
|
||||
report.description,
|
||||
report.type
|
||||
),
|
||||
type: "report",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#labelOrText(obj, fallback = "") {
|
||||
return obj.text || (obj.label ? i18n(obj.label) : fallback);
|
||||
}
|
||||
|
||||
#buildKeywords(...keywords) {
|
||||
return keywords
|
||||
.map((kw) => {
|
||||
if (Array.isArray(kw)) {
|
||||
return kw.join(" ");
|
||||
}
|
||||
return kw;
|
||||
})
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user