From e26a1175d7c33746bddbc858ad89e68cc14beefe Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 21 Feb 2025 11:59:24 +1000 Subject: [PATCH] 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 --- .../addon/components/admin-area-settings.gjs | 6 +- .../addon/components/admin-search-filters.gjs | 27 ++ .../admin/addon/components/admin-search.gjs | 124 +++++++ .../addon/components/modal/admin-search.gjs | 20 ++ .../controllers/admin-area-settings-base.js | 14 +- .../addon/controllers/admin-site-settings.js | 4 +- .../admin/addon/mixins/setting-component.js | 98 +----- .../admin/addon/routes/admin-config-about.js | 2 +- .../addon/routes/admin-config-developer.js | 6 +- .../addon/routes/admin-config-experimental.js | 6 +- .../admin/addon/routes/admin-config-files.js | 6 +- .../routes/admin-config-flags-settings.js | 8 +- .../admin/addon/routes/admin-config-fonts.js | 6 +- .../routes/admin-config-group-permissions.js | 6 +- .../admin/addon/routes/admin-config-legal.js | 6 +- .../addon/routes/admin-config-localization.js | 6 +- .../admin-config-login-and-authentication.js | 6 +- .../admin/addon/routes/admin-config-logo.js | 6 +- .../admin-config-look-and-feel-index.js | 4 +- .../addon/routes/admin-config-navigation.js | 6 +- .../routes/admin-config-notifications.js | 6 +- .../admin/addon/routes/admin-config-onebox.js | 6 +- .../admin/addon/routes/admin-config-other.js | 6 +- .../addon/routes/admin-config-rate-limits.js | 6 +- .../admin/addon/routes/admin-config-search.js | 6 +- .../addon/routes/admin-config-security.js | 6 +- .../admin/addon/routes/admin-config-spam.js | 6 +- .../addon/routes/admin-config-trust-levels.js | 6 +- .../addon/routes/admin-config-user-api.js | 6 +- .../admin-config-with-settings-route.js | 15 + .../routes/admin-plugins-show-settings.js | 6 + .../admin/addon/routes/admin-site-settings.js | 6 + .../javascripts/admin/addon/routes/admin.js | 25 ++ .../services/admin-search-data-source.js | 320 +++++++++++++++++ .../admin/addon/templates/admin-badges.hbs | 6 +- .../admin/addon/templates/api-keys.gjs | 6 +- .../admin/addon/templates/backups.hbs | 6 +- .../admin/addon/templates/config-about.hbs | 6 +- .../addon/templates/customize-colors.hbs | 6 +- .../addon/templates/customize-email-style.hbs | 4 +- .../addon/templates/customize-themes.hbs | 12 +- .../addon/templates/email-advanced-test.hbs | 6 +- .../addon/templates/email-preview-digest.hbs | 6 +- .../admin/addon/templates/email.hbs | 42 ++- .../admin/addon/templates/embedding.hbs | 6 +- .../admin/addon/templates/emojis.hbs | 6 +- .../admin/addon/templates/logs.hbs | 22 +- .../addon/templates/logs/screened-emails.hbs | 8 +- .../templates/logs/screened-ip-addresses.hbs | 6 +- .../addon/templates/logs/screened-urls.hbs | 9 +- .../admin/addon/templates/permalinks.hbs | 6 +- .../admin/addon/templates/plugins-index.hbs | 4 +- .../admin/addon/templates/plugins.hbs | 2 +- .../admin/addon/templates/reports-index.hbs | 8 +- .../admin/addon/templates/section-account.hbs | 12 +- .../admin/addon/templates/site-settings.hbs | 6 +- .../admin/addon/templates/site-text.hbs | 6 +- .../admin/addon/templates/user-fields.hbs | 6 +- .../admin/addon/templates/users-list.hbs | 6 +- .../admin/addon/templates/watched-words.hbs | 6 +- .../admin/addon/templates/web-hooks.gjs | 6 +- .../admin/addon/templates/whats-new.hbs | 6 +- .../app/components/d-page-subheader.gjs | 18 +- .../discourse/app/lib/keyboard-shortcuts.js | 5 +- .../app/lib/sidebar/admin-nav-map.js | 322 +++++++++++++++--- .../app/lib/sidebar/admin-sidebar.js | 53 +-- .../discourse/app/lib/site-settings-utils.js | 96 ++++++ .../acceptance/admin-site-settings-test.js | 5 +- .../stylesheets/common/admin/admin_base.scss | 1 + .../stylesheets/common/admin/search.scss | 129 +++++++ app/controllers/admin/search_controller.rb | 21 ++ app/serializers/basic_theme_serializer.rb | 6 +- app/serializers/current_user_serializer.rb | 5 + app/serializers/theme_serializer.rb | 5 - config/locales/client.en.yml | 266 +++++++++------ config/routes.rb | 4 +- config/site_settings.yml | 9 + lib/application_layout_preloader.rb | 1 + lib/plugin/instance.rb | 9 +- lib/site_setting_extension.rb | 24 +- plugins/chat/plugin.rb | 2 +- .../requests/application_controller_spec.rb | 2 + .../config/locales/client.en.yml | 5 + .../config/locales/client.en.yml | 5 + .../config/locales/client.en.yml | 5 + plugins/footnote/config/locales/client.en.yml | 5 + plugins/poll/config/locales/client.en.yml | 5 + .../config/locales/client.en.yml | 5 + .../styleguide/config/locales/client.en.yml | 5 + spec/system/admin_sidebar_navigation_spec.rb | 65 ++-- 90 files changed, 1582 insertions(+), 525 deletions(-) create mode 100644 app/assets/javascripts/admin/addon/components/admin-search-filters.gjs create mode 100644 app/assets/javascripts/admin/addon/components/admin-search.gjs create mode 100644 app/assets/javascripts/admin/addon/components/modal/admin-search.gjs create mode 100644 app/assets/javascripts/admin/addon/routes/admin-config-with-settings-route.js create mode 100644 app/assets/javascripts/admin/addon/services/admin-search-data-source.js create mode 100644 app/assets/javascripts/discourse/app/lib/site-settings-utils.js create mode 100644 app/assets/stylesheets/common/admin/search.scss create mode 100644 app/controllers/admin/search_controller.rb diff --git a/app/assets/javascripts/admin/addon/components/admin-area-settings.gjs b/app/assets/javascripts/admin/addon/components/admin-area-settings.gjs index e8f60b6ea03..7566de4f7bd 100644 --- a/app/assets/javascripts/admin/addon/components/admin-area-settings.gjs +++ b/app/assets/javascripts/admin/addon/components/admin-area-settings.gjs @@ -16,7 +16,6 @@ export default class AdminAreaSettings extends Component { @service siteSettings; @service router; @tracked settings = []; - @tracked filter = ""; @tracked loading = false; @tracked showBreadcrumb = this.args.showBreadcrumb ?? true; @@ -37,7 +36,6 @@ export default class AdminAreaSettings extends Component { @bind async #loadSettings() { this.loading = true; - this.filter = this.args.filter; try { const result = await ajax("/admin/config/site_settings.json", { data: { @@ -63,6 +61,10 @@ export default class AdminAreaSettings extends Component { } } + get filter() { + return this.args.filter ?? ""; + } + @action adminSettingsFilterChangedCallback(filterData) { this.args.adminSettingsFilterChangedCallback(filterData.filter); diff --git a/app/assets/javascripts/admin/addon/components/admin-search-filters.gjs b/app/assets/javascripts/admin/addon/components/admin-search-filters.gjs new file mode 100644 index 00000000000..18c55173d06 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-search-filters.gjs @@ -0,0 +1,27 @@ +import { concat, fn, get } from "@ember/helper"; +import DButton from "discourse/components/d-button"; +import concatClass from "discourse/helpers/concat-class"; +import { i18n } from "discourse-i18n"; + +const AdminSearchFilters = ; + +export default AdminSearchFilters; diff --git a/app/assets/javascripts/admin/addon/components/admin-search.gjs b/app/assets/javascripts/admin/addon/components/admin-search.gjs new file mode 100644 index 00000000000..2b9a7306331 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-search.gjs @@ -0,0 +1,124 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import { TrackedObject } from "@ember-compat/tracked-built-ins"; +import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; +import DButton from "discourse/components/d-button"; +import icon from "discourse/helpers/d-icon"; +import discourseDebounce from "discourse/lib/debounce"; +import { INPUT_DELAY } from "discourse/lib/environment"; +import autoFocus from "discourse/modifiers/auto-focus"; +import AdminSearchFilters from "admin/components/admin-search-filters"; +import { RESULT_TYPES } from "admin/services/admin-search-data-source"; + +export default class AdminSearch extends Component { + @service adminSearchDataSource; + + @tracked filter = ""; + @tracked searchResults = []; + @tracked showFilters = false; + @tracked loading = false; + typeFilters = new TrackedObject({ + page: true, + setting: true, + theme: true, + component: true, + report: true, + }); + + constructor() { + super(...arguments); + this.adminSearchDataSource.buildMap(); + } + + get visibleTypes() { + return Object.keys(this.typeFilters).filter( + (type) => this.typeFilters[type] + ); + } + + get showLoadingSpinner() { + return !this.adminSearchDataSource.isLoaded || this.loading; + } + + @action + toggleFilters() { + this.showFilters = !this.showFilters; + } + + @action + toggleTypeFilter(type) { + this.typeFilters[type] = !this.typeFilters[type]; + this.search(); + } + + @action + changeSearchTerm(event) { + this.searchResults = []; + this.filter = event.target.value; + this.loading = true; + this.search(); + } + + @action + search() { + discourseDebounce(this, this.#search, INPUT_DELAY); + } + + #search() { + this.searchResults = this.adminSearchDataSource.search(this.filter, { + types: this.visibleTypes, + }); + this.loading = false; + } + + +} diff --git a/app/assets/javascripts/admin/addon/components/modal/admin-search.gjs b/app/assets/javascripts/admin/addon/components/modal/admin-search.gjs new file mode 100644 index 00000000000..189bf8ecce9 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/modal/admin-search.gjs @@ -0,0 +1,20 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import DModal from "discourse/components/d-modal"; +import AdminSearch from "admin/components/admin-search"; + +export default class AdminSearchModal extends Component { + @service currentUser; + + +} diff --git a/app/assets/javascripts/admin/addon/controllers/admin-area-settings-base.js b/app/assets/javascripts/admin/addon/controllers/admin-area-settings-base.js index 02a46aab054..dc61713dd8b 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-area-settings-base.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-area-settings-base.js @@ -1,13 +1,19 @@ -import { tracked } from "@glimmer/tracking"; import Controller from "@ember/controller"; import { action } from "@ember/object"; export default class AdminAreaSettingsBaseController extends Controller { - @tracked filter = ""; - queryParams = ["filter"]; + filter = ""; + queryParams = [ + { + filter: { replace: true }, + }, + ]; @action adminSettingsFilterChangedCallback(filter) { - this.filter = filter; + if (this.filter === filter) { + return; + } + this.set("filter", filter); } } diff --git a/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js b/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js index e1de5ee87fa..7157ac5598a 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-site-settings.js @@ -23,6 +23,8 @@ export default class AdminSiteSettingsController extends Controller { return; } + this.set("filter", filterData.filter); + if (isEmpty(filterData.filter) && !filterData.onlyOverridden) { this.set("visibleSiteSettings", this.allSiteSettings); if (this.categoryNameKey === "all_results") { @@ -31,8 +33,6 @@ export default class AdminSiteSettingsController extends Controller { return; } - this.set("filter", filterData.filter); - const matchesGroupedByCategory = this.siteSettingFilter.filterSettings( filterData.filter, { onlyOverridden: filterData.onlyOverridden } diff --git a/app/assets/javascripts/admin/addon/mixins/setting-component.js b/app/assets/javascripts/admin/addon/mixins/setting-component.js index 7253f19c0b2..6ace0022a2f 100644 --- a/app/assets/javascripts/admin/addon/mixins/setting-component.js +++ b/app/assets/javascripts/admin/addon/mixins/setting-component.js @@ -11,6 +11,7 @@ import { ajax } from "discourse/lib/ajax"; import { fmt, propertyNotEqual } from "discourse/lib/computed"; import { SITE_SETTING_REQUIRES_CONFIRMATION_TYPES } from "discourse/lib/constants"; import { deepEqual } from "discourse/lib/object"; +import { humanizedSettingName } from "discourse/lib/site-settings-utils"; import { splitString } from "discourse/lib/utilities"; import { i18n } from "discourse-i18n"; import SiteSettingDefaultCategoriesModal from "../components/modal/site-setting-default-categories"; @@ -82,79 +83,6 @@ const DEFAULT_USER_PREFERENCES = [ "default_sidebar_show_count_of_new_items", ]; -const ACRONYMS = new Set([ - "acl", - "ai", - "api", - "bg", - "cdn", - "cors", - "cta", - "dm", - "eu", - "faq", - "fg", - "ga", - "gb", - "gtm", - "hd", - "http", - "https", - "iam", - "id", - "imap", - "ip", - "jpg", - "json", - "kb", - "mb", - "oidc", - "pm", - "png", - "pop3", - "s3", - "smtp", - "svg", - "tl", - "tl0", - "tl1", - "tl2", - "tl3", - "tl4", - "tld", - "txt", - "url", - "ux", -]); - -const MIXED_CASE = [ - ["adobe analytics", "Adobe Analytics"], - ["android", "Android"], - ["chinese", "Chinese"], - ["discord", "Discord"], - ["discourse", "Discourse"], - ["discourse connect", "Discourse Connect"], - ["discourse discover", "Discourse Discover"], - ["discourse narrative bot", "Discourse Narrative Bot"], - ["facebook", "Facebook"], - ["github", "GitHub"], - ["google", "Google"], - ["gravatar", "Gravatar"], - ["gravatars", "Gravatars"], - ["ios", "iOS"], - ["japanese", "Japanese"], - ["linkedin", "LinkedIn"], - ["oauth2", "OAuth2"], - ["opengraph", "OpenGraph"], - ["powered by discourse", "Powered by Discourse"], - ["tiktok", "TikTok"], - ["tos", "ToS"], - ["twitter", "Twitter"], - ["vimeo", "Vimeo"], - ["wordpress", "WordPress"], - ["youtube", "YouTube"], -]; - export default Mixin.create({ modal: service(), router: service(), @@ -215,29 +143,7 @@ export default Mixin.create({ }), settingName: computed("setting.setting", "setting.label", function () { - const setting = this.setting?.setting; - const label = this.setting?.label; - const name = label || setting.replace(/\_/g, " "); - - const formattedName = (name.charAt(0).toUpperCase() + name.slice(1)) - .split(" ") - .map((word) => - ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word - ) - .map((word) => { - if (word.endsWith("s")) { - const singular = word.slice(0, -1).toLowerCase(); - return ACRONYMS.has(singular) ? singular.toUpperCase() + "s" : word; - } - return word; - }) - .join(" "); - - return MIXED_CASE.reduce( - (acc, [key, value]) => - acc.replaceAll(new RegExp(`\\b${key}\\b`, "gi"), value), - formattedName - ); + return humanizedSettingName(this.setting.setting, this.setting.label); }), componentType: computed("type", function () { diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-about.js b/app/assets/javascripts/admin/addon/routes/admin-config-about.js index 0edc9da96db..4c59d6ca7d5 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-about.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-about.js @@ -4,7 +4,7 @@ import { i18n } from "discourse-i18n"; export default class AdminConfigAboutRoute extends DiscourseRoute { titleToken() { - return i18n("admin.community.sidebar_link.about_your_site"); + return i18n("admin.config.about.title"); } model() { diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-developer.js b/app/assets/javascripts/admin/addon/routes/admin-config-developer.js index 27761603c9b..b074d8d67e1 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-developer.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-developer.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigDeveloperRoute extends DiscourseRoute { +export default class AdminConfigDeveloperRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.developer"); + return i18n("admin.config.developer.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-experimental.js b/app/assets/javascripts/admin/addon/routes/admin-config-experimental.js index 898a0966454..d0a4793e92e 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-experimental.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-experimental.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigExperimentalRoute extends DiscourseRoute { +export default class AdminConfigExperimentalRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.experimental"); + return i18n("admin.config.experimental.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-files.js b/app/assets/javascripts/admin/addon/routes/admin-config-files.js index a920aa9f6ec..0140fb1fcb5 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-files.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-files.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigFilesRoute extends DiscourseRoute { +export default class AdminConfigFilesRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.files"); + return i18n("admin.config.files.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-flags-settings.js b/app/assets/javascripts/admin/addon/routes/admin-config-flags-settings.js index 7f4fc2bc1e1..f7402d3534d 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-flags-settings.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-flags-settings.js @@ -1,8 +1,14 @@ import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; -export default class AdminConfigFlagsIndexRoute extends DiscourseRoute { +export default class AdminConfigFlagsSettingsRoute extends DiscourseRoute { titleToken() { return i18n("admin.config_areas.flags.settings"); } + + resetController(controller, isExiting) { + if (isExiting) { + controller.set("filter", ""); + } + } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-fonts.js b/app/assets/javascripts/admin/addon/routes/admin-config-fonts.js index c5e53323e7e..00c88e5afbb 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-fonts.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-fonts.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigFontsRoute extends DiscourseRoute { +export default class AdminConfigFontsRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.appearance.sidebar_link.font_style"); + return i18n("admin.config.font_style.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-group-permissions.js b/app/assets/javascripts/admin/addon/routes/admin-config-group-permissions.js index 3e123fe30ac..74f8ba0310c 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-group-permissions.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-group-permissions.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigGroupPermissionsRoute extends DiscourseRoute { +export default class AdminConfigGroupPermissionsRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.community.sidebar_link.group_permissions"); + return i18n("admin.config.group_permissions.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-legal.js b/app/assets/javascripts/admin/addon/routes/admin-config-legal.js index a6c1adb68d0..dffcd6eaf5a 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-legal.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-legal.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigLegalRoute extends DiscourseRoute { +export default class AdminConfigLegalRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.community.sidebar_link.legal"); + return i18n("admin.config.legal.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-localization.js b/app/assets/javascripts/admin/addon/routes/admin-config-localization.js index 1c942db0f26..d07d8b27714 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-localization.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-localization.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigLocalizationRoute extends DiscourseRoute { +export default class AdminConfigLocalizationRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.community.sidebar_link.localization.title"); + return i18n("admin.config.localization.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-login-and-authentication.js b/app/assets/javascripts/admin/addon/routes/admin-config-login-and-authentication.js index bb18d420331..5e4ae73e16b 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-login-and-authentication.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-login-and-authentication.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigLoginAndAuthenticationRoute extends DiscourseRoute { +export default class AdminConfigLoginAndAuthenticationRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.community.sidebar_link.login_and_authentication"); + return i18n("admin.config.login_and_authentication.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-logo.js b/app/assets/javascripts/admin/addon/routes/admin-config-logo.js index 2c8548a5f4e..c983ebbcada 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-logo.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-logo.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigLogoRoute extends DiscourseRoute { +export default class AdminConfigLogoRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.appearance.sidebar_link.site_logo"); + return i18n("admin.config.logo.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-look-and-feel-index.js b/app/assets/javascripts/admin/addon/routes/admin-config-look-and-feel-index.js index c8d4c8dd0e4..6d88295b926 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-look-and-feel-index.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-look-and-feel-index.js @@ -1,7 +1,7 @@ import { service } from "@ember/service"; -import DiscourseRoute from "discourse/routes/discourse"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigLookAndFeelIndexRoute extends DiscourseRoute { +export default class AdminConfigLookAndFeelIndexRoute extends AdminConfigWithSettingsRoute { @service router; beforeModel() { diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-navigation.js b/app/assets/javascripts/admin/addon/routes/admin-config-navigation.js index 4e5d06e824a..ba1c15b04ba 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-navigation.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-navigation.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigNavigationRoute extends DiscourseRoute { +export default class AdminConfigNavigationRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.appearance.sidebar_link.navigation"); + return i18n("admin.config.navigation.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-notifications.js b/app/assets/javascripts/admin/addon/routes/admin-config-notifications.js index 1c437d8b632..150b9dade79 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-notifications.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-notifications.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigNotificationsRoute extends DiscourseRoute { +export default class AdminConfigNotificationsRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.community.sidebar_link.notifications"); + return i18n("admin.config.notifications.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-onebox.js b/app/assets/javascripts/admin/addon/routes/admin-config-onebox.js index 1a9e653a687..95c4ac18430 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-onebox.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-onebox.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigOneboxRoute extends DiscourseRoute { +export default class AdminConfigOneboxRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.onebox"); + return i18n("admin.config.onebox.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-other.js b/app/assets/javascripts/admin/addon/routes/admin-config-other.js index 7f16e2e3e67..b60f293f070 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-other.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-other.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigOtherRoute extends DiscourseRoute { +export default class AdminConfigOtherRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.other_options"); + return i18n("admin.config.other.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-rate-limits.js b/app/assets/javascripts/admin/addon/routes/admin-config-rate-limits.js index c13d23956bb..f5c72901087 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-rate-limits.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-rate-limits.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigRateLimitsRoute extends DiscourseRoute { +export default class AdminConfigRateLimitsRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.rate_limits"); + return i18n("admin.config.rate_limits.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-search.js b/app/assets/javascripts/admin/addon/routes/admin-config-search.js index b43bc8858cb..b6f8c66a252 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-search.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-search.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigSearchRoute extends DiscourseRoute { +export default class AdminConfigSearchRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.search"); + return i18n("admin.config.search.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-security.js b/app/assets/javascripts/admin/addon/routes/admin-config-security.js index 243fbac6438..35cb81afeaa 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-security.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-security.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigSecurityRoute extends DiscourseRoute { +export default class AdminConfigSecurityRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.security.sidebar_link.security"); + return i18n("admin.config.security.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-spam.js b/app/assets/javascripts/admin/addon/routes/admin-config-spam.js index 4fb53202375..5fb7d507ab1 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-spam.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-spam.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigSpamRoute extends DiscourseRoute { +export default class AdminConfigSpamRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.security.sidebar_link.spam"); + return i18n("admin.config.spam.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-trust-levels.js b/app/assets/javascripts/admin/addon/routes/admin-config-trust-levels.js index 40c7f9b6763..51063bf75e2 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-trust-levels.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-trust-levels.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigTrustLevelsRoute extends DiscourseRoute { +export default class AdminConfigTrustLevelsRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.community.sidebar_link.trust_levels"); + return i18n("admin.config.trust_levels.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-user-api.js b/app/assets/javascripts/admin/addon/routes/admin-config-user-api.js index 7cc34d7bf3d..e25d14d8439 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-config-user-api.js +++ b/app/assets/javascripts/admin/addon/routes/admin-config-user-api.js @@ -1,8 +1,8 @@ -import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminConfigWithSettingsRoute from "./admin-config-with-settings-route"; -export default class AdminConfigUserApiRoute extends DiscourseRoute { +export default class AdminConfigUserApiRoute extends AdminConfigWithSettingsRoute { titleToken() { - return i18n("admin.advanced.sidebar_link.user_api"); + return i18n("admin.config.user_api.title"); } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-config-with-settings-route.js b/app/assets/javascripts/admin/addon/routes/admin-config-with-settings-route.js new file mode 100644 index 00000000000..aadcdd7ca79 --- /dev/null +++ b/app/assets/javascripts/admin/addon/routes/admin-config-with-settings-route.js @@ -0,0 +1,15 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class AdminConfigWithSettingsRoute extends DiscourseRoute { + resetController(controller, isExiting) { + // Have to do this because this is the parent route. We don't want to have + // to make a controller for every single settings route when we can reset + // the filter here. + const settingsController = this.controllerFor( + `${this.fullRouteName}.settings` + ); + if (isExiting) { + settingsController.set("filter", ""); + } + } +} diff --git a/app/assets/javascripts/admin/addon/routes/admin-plugins-show-settings.js b/app/assets/javascripts/admin/addon/routes/admin-plugins-show-settings.js index b1c762edc93..f3ad08b93ee 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-plugins-show-settings.js +++ b/app/assets/javascripts/admin/addon/routes/admin-plugins-show-settings.js @@ -20,4 +20,10 @@ export default class AdminPluginsShowSettingsRoute extends DiscourseRoute { titleToken() { return i18n("admin.plugins.change_settings_short"); } + + resetController(controller, isExiting) { + if (isExiting) { + controller.set("filter", ""); + } + } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-site-settings.js b/app/assets/javascripts/admin/addon/routes/admin-site-settings.js index a6fe9a12955..ce7583486be 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-site-settings.js +++ b/app/assets/javascripts/admin/addon/routes/admin-site-settings.js @@ -25,4 +25,10 @@ export default class AdminSiteSettingsRoute extends DiscourseRoute { this.controllerFor("adminSiteSettings").set("model", settings); }); } + + resetController(controller, isExiting) { + if (isExiting) { + controller.set("filter", ""); + } + } } diff --git a/app/assets/javascripts/admin/addon/routes/admin.js b/app/assets/javascripts/admin/addon/routes/admin.js index 5ea893f164c..5699fb1a83a 100644 --- a/app/assets/javascripts/admin/addon/routes/admin.js +++ b/app/assets/javascripts/admin/addon/routes/admin.js @@ -1,7 +1,11 @@ import { tracked } from "@glimmer/tracking"; import { service } from "@ember/service"; +import KeyboardShortcuts, { + PLATFORM_KEY_MODIFIER, +} from "discourse/lib/keyboard-shortcuts"; import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; +import AdminSearchModal from "admin/components/modal/admin-search"; export default class AdminRoute extends DiscourseRoute { @service sidebarState; @@ -9,6 +13,7 @@ export default class AdminRoute extends DiscourseRoute { @service store; @service currentUser; @service adminSidebarStateManager; + @service modal; @tracked initialSidebarState; titleToken() { @@ -16,6 +21,16 @@ export default class AdminRoute extends DiscourseRoute { } activate() { + if (this.currentUser.use_experimental_admin_search) { + KeyboardShortcuts.addShortcut( + `${PLATFORM_KEY_MODIFIER}+/`, + () => this.showAdminSearchModal(), + { + global: true, + } + ); + } + this.adminSidebarStateManager.maybeForceAdminSidebar({ onlyIfAlreadyActive: false, }); @@ -28,10 +43,20 @@ export default class AdminRoute extends DiscourseRoute { deactivate(transition) { this.controllerFor("application").set("showTop", true); + if (this.currentUser.use_experimental_admin_search) { + KeyboardShortcuts.unbind({ + [`${PLATFORM_KEY_MODIFIER}+/`]: this.showAdminSearchModal, + }); + } + if (this.adminSidebarStateManager.currentUserUsingAdminSidebar) { if (!transition?.to.name.startsWith("admin")) { this.adminSidebarStateManager.stopForcingAdminSidebar(); } } } + + showAdminSearchModal() { + this.modal.show(AdminSearchModal); + } } diff --git a/app/assets/javascripts/admin/addon/services/admin-search-data-source.js b/app/assets/javascripts/admin/addon/services/admin-search-data-source.js new file mode 100644 index 00000000000..406d386a370 --- /dev/null +++ b/app/assets/javascripts/admin/addon/services/admin-search-data-source.js @@ -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(); + } +} diff --git a/app/assets/javascripts/admin/addon/templates/admin-badges.hbs b/app/assets/javascripts/admin/addon/templates/admin-badges.hbs index 3a94b3a7969..1d257fbd164 100644 --- a/app/assets/javascripts/admin/addon/templates/admin-badges.hbs +++ b/app/assets/javascripts/admin/addon/templates/admin-badges.hbs @@ -1,14 +1,14 @@
<:breadcrumbs> <:actions as |actions|> diff --git a/app/assets/javascripts/admin/addon/templates/api-keys.gjs b/app/assets/javascripts/admin/addon/templates/api-keys.gjs index 9003d8210d4..b7ef4c64f74 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys.gjs +++ b/app/assets/javascripts/admin/addon/templates/api-keys.gjs @@ -7,15 +7,15 @@ import { i18n } from "discourse-i18n"; export default RouteTemplate(