discourse/app/assets/javascripts/admin/addon/services/admin-search-data-source.js
Martin Brennan b329eac79a
FIX: Handle href admin sidebar links better (#31575)
Admin sidebar links can have either a `href` or a
`route`, and the admin search was not handling
this properly. Also, we should always use `getURL()`
on the href in case the link is internal, for subfolder
sites.

This is hard to test right now, I plan on adding more extensive
links for admin-search-data-source in another PR.
2025-03-04 13:38:49 +10:00

325 lines
9.2 KiB
JavaScript

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 { ADMIN_SEARCH_RESULT_TYPES } from "discourse/lib/constants";
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";
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);
await Promise.resolve();
this._mapCached = true;
}
search(filter, opts = {}) {
if (filter.length < MIN_FILTER_LENGTH) {
return [];
}
opts.types = opts.types || ADMIN_SEARCH_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.route) {
if (link.routeModels) {
url = this.router.urlFor(link.route, ...link.routeModels);
} else {
url = this.router.urlFor(link.route);
}
} else if (link.href) {
url = getURL(link.href);
}
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();
}
}