diff --git a/app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs b/app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs
new file mode 100644
index 0000000000000..d46c208e41eff
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs
@@ -0,0 +1,35 @@
+import Component from "@glimmer/component";
+import { service } from "@ember/service";
+import { i18n } from "discourse-i18n";
+
+export default class FilterNoResults extends Component {
+ @service sidebarState;
+
+ get shouldDisplay() {
+ return (
+ this.sidebarState.currentPanel.filterable &&
+ !!(this.args.sections?.length === 0)
+ );
+ }
+
+ get noResultsDescription() {
+ return this.sidebarState.currentPanel.filterNoResultsDescription(
+ this.sidebarState.filter
+ );
+ }
+
+
+ {{#if this.shouldDisplay}}
+
+ {{/if}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/filter.gjs b/app/assets/javascripts/discourse/app/components/sidebar/filter.gjs
new file mode 100644
index 0000000000000..d783f2d7835b9
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/sidebar/filter.gjs
@@ -0,0 +1,61 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import { i18n } from "discourse-i18n";
+
+export default class Filter extends Component {
+ @service sidebarState;
+ @service router;
+ @service currentUser;
+
+ willDestroy() {
+ super.willDestroy(...arguments);
+ this.sidebarState.clearFilter();
+ }
+
+ get shouldDisplay() {
+ return this.sidebarState.currentPanel.filterable;
+ }
+
+ get displayClearFilter() {
+ return this.sidebarState.filter.length > 0;
+ }
+
+ @action
+ setFilter(event) {
+ this.sidebarState.filter = event.target.value;
+ }
+
+ @action
+ clearFilter() {
+ this.sidebarState.clearFilter();
+ document.querySelector(".sidebar-filter__input").focus();
+ }
+
+
+ {{#if this.shouldDisplay}}
+
+ {{/if}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/panel-header.gjs b/app/assets/javascripts/discourse/app/components/sidebar/panel-header.gjs
index 02d6c2411019b..8f8943196901d 100644
--- a/app/assets/javascripts/discourse/app/components/sidebar/panel-header.gjs
+++ b/app/assets/javascripts/discourse/app/components/sidebar/panel-header.gjs
@@ -2,10 +2,13 @@ import Component from "@glimmer/component";
import { service } from "@ember/service";
import BackToForum from "discourse/components/sidebar/back-to-forum";
import Search from "discourse/components/sidebar/search";
+import Filter from "./filter";
+import FilterNoResults from "./filter-no-results";
import ToggleAllSections from "./toggle-all-sections";
export default class PanelHeader extends Component {
@service sidebarState;
+ @service currentUser;
get shouldDisplay() {
return this.sidebarState.currentPanel.displayHeader;
@@ -20,7 +23,9 @@ export default class PanelHeader extends Component {
+
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js b/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js
index 936cf4284e489..1214c5cfbf4f6 100644
--- a/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js
+++ b/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js
@@ -10,6 +10,7 @@ import BaseCustomSidebarPanel from "discourse/lib/sidebar/base-custom-sidebar-pa
import BaseCustomSidebarSection from "discourse/lib/sidebar/base-custom-sidebar-section";
import BaseCustomSidebarSectionLink from "discourse/lib/sidebar/base-custom-sidebar-section-link";
import { ADMIN_PANEL } from "discourse/lib/sidebar/panels";
+import { escapeExpression } from "discourse/lib/utilities";
import I18n, { i18n } from "discourse-i18n";
let additionalAdminSidebarSectionLinks = {};
@@ -409,7 +410,25 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel {
}
get searchable() {
- return true;
+ const currentUser = getOwnerWithFallback(this).lookup(
+ "service:current-user"
+ );
+ return currentUser.admin;
+ }
+
+ get filterable() {
+ const currentUser = getOwnerWithFallback(this).lookup(
+ "service:current-user"
+ );
+ return !currentUser.admin && currentUser.moderator;
+ }
+
+ filterNoResultsDescription(filter) {
+ const escapedFilter = escapeExpression(filter);
+
+ i18n("sidebar.no_results.description_admin_search", {
+ filter: escapedFilter,
+ });
}
get onSearchClick() {
diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js b/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js
index e415fb04cd4a1..04bd0d6f6bcf6 100644
--- a/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js
+++ b/app/assets/javascripts/discourse/app/lib/sidebar/base-custom-sidebar-panel.js
@@ -55,7 +55,27 @@ export default class BaseCustomSidebarPanel {
}
/**
- * @returns {boolean} Controls whether the search is shown
+ * @returns {boolean} Controls whether the filter is shown.
+ * Filter allows to remove sidebar links which does not match the filter phrase.
+ */
+ get filterable() {
+ return false;
+ }
+
+ /**
+ * @param {string} filter filter applied
+ *
+ * @returns {string | SafeString} Description displayed when the applied filter has no results.
+ * Use `htmlSafe` from `from "@ember/template` to use HTML strings.
+ */
+ // eslint-disable-next-line no-unused-vars
+ filterNoResultsDescription(filter) {
+ return null;
+ }
+
+ /**
+ * @returns {boolean} Controls whether the search is shown.
+ * Displays modal on click allowing searching for admin pages, site settings, themes, components and reports.
*/
get searchable() {
return false;
diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss
index 2092b779fd9af..cef39f77a29d8 100644
--- a/app/assets/stylesheets/common/base/menu-panel.scss
+++ b/app/assets/stylesheets/common/base/menu-panel.scss
@@ -233,6 +233,10 @@
vertical-align: text-bottom;
}
+ .sidebar-filter {
+ width: 100%;
+ }
+
.sidebar-search {
width: 100%;
}
diff --git a/app/assets/stylesheets/common/base/sidebar.scss b/app/assets/stylesheets/common/base/sidebar.scss
index 571ab10c5066e..0f3f12989bfaa 100644
--- a/app/assets/stylesheets/common/base/sidebar.scss
+++ b/app/assets/stylesheets/common/base/sidebar.scss
@@ -353,6 +353,68 @@
}
}
+.sidebar-filter {
+ margin-top: 1em;
+ margin-bottom: 1em;
+ border: 1px solid var(--primary-400);
+ border-radius: var(--d-input-border-radius);
+ background: var(--secondary);
+ width: calc(
+ var(--d-sidebar-width) - 2 * var(--d-sidebar-row-horizontal-padding)
+ );
+
+ &:focus-within {
+ border-color: var(--tertiary);
+ outline: 1px solid var(--tertiary);
+ outline-offset: -1px;
+ }
+
+ &__input-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ background: var(--secondary);
+ border-radius: var(--d-input-border-radius);
+ }
+
+ &__shortcut-hint {
+ background-color: rgb(var(--tertiary-rgb) / 10%);
+ padding: 0.25em 0.5em;
+ margin-right: 0.5em;
+ font-size: var(--font-down-3);
+ color: var(--primary-medium);
+ }
+
+ &__input[type="text"] {
+ border: 0;
+ background: none;
+ margin-bottom: 0;
+ height: 2em;
+ width: 100%;
+
+ &:focus-within {
+ outline: 0;
+ }
+ }
+
+ &__clear {
+ width: 2em;
+ height: 2em;
+ color: var(--primary-medium);
+ background-color: var(--secondary);
+ }
+}
+
+.sidebar-no-results {
+ display: block;
+ margin: 0.5em var(--d-sidebar-row-horizontal-padding) 0
+ var(--d-sidebar-row-horizontal-padding);
+
+ &__title {
+ font-weight: bold;
+ }
+}
+
.sidebar-search {
width: 100%;
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 25886d02b1713..78edbfd6ec020 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -5096,6 +5096,11 @@ en:
forum:
label: Forum
back_to_forum: "Back to Forum"
+ filter_links: "Filter links..."
+ clear_filter: "Clear filter"
+ no_results:
+ title: "No results"
+ description_admin_search: 'We couldn’t find anything matching ‘%{filter}’.'
collapse_all_sections: "Collapse all sections"
expand_all_sections: "Expand all sections"
search: "Search"
diff --git a/spec/system/admin_sidebar_navigation_spec.rb b/spec/system/admin_sidebar_navigation_spec.rb
index 7f8bde374d00d..86cbede331025 100644
--- a/spec/system/admin_sidebar_navigation_spec.rb
+++ b/spec/system/admin_sidebar_navigation_spec.rb
@@ -8,6 +8,7 @@
let(:sidebar) { PageObjects::Components::NavigationMenu::Sidebar.new }
let(:sidebar_dropdown) { PageObjects::Components::SidebarHeaderDropdown.new }
+ let(:filter) { PageObjects::Components::Filter.new }
before do
SiteSetting.navigation_menu = "sidebar"
@@ -158,5 +159,10 @@
I18n.t("admin_js.admin.config.staff_action_logs.title"),
],
)
+
+ filter.filter("watched")
+ links = page.all(".sidebar-section-link-content-text")
+ expect(links.count).to eq(1)
+ expect(links.map(&:text)).to eq(["Watched words"])
end
end
diff --git a/spec/system/page_objects/components/filter.rb b/spec/system/page_objects/components/filter.rb
new file mode 100644
index 0000000000000..3bcdd671e4a77
--- /dev/null
+++ b/spec/system/page_objects/components/filter.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class Filter < PageObjects::Components::Base
+ def filter(text)
+ page.find(".sidebar-filter__input").fill_in(with: text)
+ self
+ end
+
+ def clear
+ page.find(".sidebar-filter__clear").click
+ self
+ end
+ end
+ end
+end