From 8b5204579c3b96333b6358a291acf8ce5c9d60b7 Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Wed, 28 Feb 2024 12:15:02 +1100 Subject: [PATCH] FEATURE: filter admin sidebar (#25853) Ability to filter admin sidebar. The filter can be cleared. In addition, it can be accessed with ctrl+/ shortcut --- .../modal/keyboard-shortcuts-help.js | 4 + .../app/components/sidebar/api-section.hbs | 91 ++++++++++--------- .../app/components/sidebar/api-section.js | 24 +++++ .../app/components/sidebar/api-sections.hbs | 4 +- .../components/sidebar/filter-no-results.gjs | 29 ++++++ .../app/components/sidebar/filter.gjs | 56 ++++++++++++ .../discourse/app/lib/keyboard-shortcuts.js | 11 +++ .../app/lib/sidebar/admin-sidebar.js | 4 + .../lib/sidebar/base-custom-sidebar-panel.js | 7 ++ .../discourse/app/services/sidebar-state.js | 6 +- .../stylesheets/common/base/menu-panel.scss | 4 + .../stylesheets/common/base/sidebar.scss | 51 +++++++++++ config/locales/client.en.yml | 6 ++ spec/system/admin_sidebar_navigation_spec.rb | 31 +++++++ spec/system/page_objects/components/filter.rb | 17 ++++ 15 files changed, 298 insertions(+), 47 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs create mode 100644 app/assets/javascripts/discourse/app/components/sidebar/filter.gjs create mode 100644 spec/system/page_objects/components/filter.rb diff --git a/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js b/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js index c721f8a00b4..dab3c29d0ee 100644 --- a/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js +++ b/app/assets/javascripts/discourse/app/components/modal/keyboard-shortcuts-help.js @@ -83,6 +83,10 @@ export default class KeyboardShortcutsHelp extends Component { keys2: [CTRL, ALT, "f"], keysDelimiter: PLUS, }), + filter_sidebar: buildShortcut("application.filter_sidebar", { + keys1: [META, "/"], + keysDelimiter: PLUS, + }), help: buildShortcut("application.help", { keys1: ["?"] }), dismiss_new: buildShortcut("application.dismiss_new", { keys1: ["x", "r"], diff --git a/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs b/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs index f080dc5fc43..8b6cb1fb812 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs +++ b/app/assets/javascripts/discourse/app/components/sidebar/api-section.hbs @@ -1,45 +1,46 @@ - - - {{#each this.section.links as |link|}} - - {{/each}} - \ No newline at end of file +{{#if this.shouldDisplay}} + + {{#each this.filteredLinks key="name" as |link|}} + + {{/each}} + +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/sidebar/api-section.js b/app/assets/javascripts/discourse/app/components/sidebar/api-section.js index 044a557e02b..65378fdee24 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/api-section.js +++ b/app/assets/javascripts/discourse/app/components/sidebar/api-section.js @@ -1,10 +1,34 @@ import Component from "@glimmer/component"; import { getOwner, setOwner } from "@ember/application"; +import { inject as service } from "@ember/service"; export default class SidebarApiSection extends Component { + @service sidebarState; + constructor() { super(...arguments); this.section = new this.args.sectionConfig(); setOwner(this.section, getOwner(this)); } + + get shouldDisplay() { + if (!this.sidebarState.currentPanel.filterable) { + return true; + } + return ( + this.sidebarState.filter.length === 0 || this.filteredLinks.length > 0 + ); + } + + get filteredLinks() { + if (!this.sidebarState.filter) { + return this.section.links; + } + if (this.section.text.toLowerCase().match(this.sidebarState.filter)) { + return this.section.links; + } + return this.section.links.filter((link) => { + return link.text.toString().toLowerCase().match(this.sidebarState.filter); + }); + } } diff --git a/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs b/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs index 0661b23b432..14280445d52 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs +++ b/app/assets/javascripts/discourse/app/components/sidebar/api-sections.hbs @@ -1,6 +1,8 @@ + {{#each this.sections as |sectionConfig|}} -{{/each}} \ No newline at end of file +{{/each}} + \ No newline at end of file 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 00000000000..5d7c42e220d --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/sidebar/filter-no-results.gjs @@ -0,0 +1,29 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import i18n from "discourse-common/helpers/i18n"; + +export default class FilterNoResulsts extends Component { + @service sidebarState; + + /** + * Component is rendered when panel is filtreable + * Visibility is additionally controlled by CSS rule `.sidebar-section-wrapper + .sidebar-no-results` + */ + get shouldDisplay() { + return this.sidebarState.currentPanel.filterable; + } + + +} 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 00000000000..d72afed6081 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/sidebar/filter.gjs @@ -0,0 +1,56 @@ +import Component from "@glimmer/component"; +import { Input } from "@ember/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import dIcon from "discourse-common/helpers/d-icon"; +import i18n from "discourse-common/helpers/i18n"; +import { bind } from "discourse-common/utils/decorators"; + +export default class Filter extends Component { + @service sidebarState; + + get shouldDisplay() { + return this.sidebarState.currentPanel.filterable; + } + + get displayClearFilter() { + return this.sidebarState.filter.length > 0; + } + + @bind + teardown() { + this.sidebarState.clearFilter(); + } + + @action + setFilter(event) { + this.sidebarState.filter = event.target.value.toLowerCase(); + } + + @action + clearFilter() { + this.sidebarState.clearFilter(); + document.querySelector(".sidebar-filter__input").focus(); + } + + +} diff --git a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js index cf421dce690..42332ed5828 100644 --- a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js +++ b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js @@ -41,6 +41,8 @@ const DEFAULT_BINDINGS = { "!": { postAction: "showFlags" }, "#": { handler: "goToPost", anonymous: true }, "/": { handler: "toggleSearch", anonymous: true }, + "meta+/": { handler: "filterSidebar", anonymous: true }, + [`${PLATFORM_KEY_MODIFIER}+/`]: { handler: "filterSidebar", anonymous: true }, "ctrl+alt+f": { handler: "toggleSearch", anonymous: true, global: true }, "=": { handler: "toggleHamburgerMenu", anonymous: true }, "?": { handler: "showHelpModal", anonymous: true }, @@ -469,6 +471,15 @@ export default { composer.focusComposer(event); }, + filterSidebar() { + const filterInput = document.querySelector(".sidebar-filter__input"); + + if (filterInput) { + this._scrollTo(0); + filterInput.focus(); + } + }, + fullscreenComposer() { const composer = getOwner(this).lookup("service:composer"); if (composer.get("model")) { 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 192b6894d6c..40646bb9d69 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/admin-sidebar.js @@ -255,4 +255,8 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel { return defineAdminSection(adminNavSectionData); }); } + + get filterable() { + return true; + } } 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 b98850b5141..d4fc099286c 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 @@ -43,6 +43,13 @@ export default class BaseCustomSidebarPanel { this.hidden || this.#notImplemented(); } + /** + * @returns {boolean} Controls whether the filter is shown + */ + get filterable() { + return false; + } + #notImplemented() { throw "not implemented"; } diff --git a/app/assets/javascripts/discourse/app/services/sidebar-state.js b/app/assets/javascripts/discourse/app/services/sidebar-state.js index 9f25c744a2c..2e24002df1f 100644 --- a/app/assets/javascripts/discourse/app/services/sidebar-state.js +++ b/app/assets/javascripts/discourse/app/services/sidebar-state.js @@ -17,10 +17,10 @@ export default class SidebarState extends Service { @tracked panels = panels; @tracked mode = COMBINED_MODE; @tracked displaySwitchPanelButtons = false; + @tracked filter = ""; constructor() { super(...arguments); - this.#reset(); } @@ -64,4 +64,8 @@ export default class SidebarState extends Service { this.panels = panels; this.mode = COMBINED_MODE; } + + clearFilter() { + this.filter = ""; + } } diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index 2ba4d097e44..f6c8b462712 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -344,6 +344,10 @@ div.discourse-tags { font-size: var(--font-down-1); } + + .sidebar-filter { + width: calc(100% - 2.35rem); + } } // Panel / user-notification-list styles. **not** menu panel sizing styles diff --git a/app/assets/stylesheets/common/base/sidebar.scss b/app/assets/stylesheets/common/base/sidebar.scss index e34175c8ce4..29dad70e743 100644 --- a/app/assets/stylesheets/common/base/sidebar.scss +++ b/app/assets/stylesheets/common/base/sidebar.scss @@ -306,3 +306,54 @@ margin-bottom: 1em; } } + +.sidebar-filter { + margin: 0 var(--d-sidebar-row-horizontal-padding) 0.5em + var(--d-sidebar-row-horizontal-padding); + display: flex; + border: 1px solid var(--primary-400); + border-radius: var(--d-input-border-radius); + align-items: center; + justify-content: space-between; + background: var(--secondary); + width: calc( + var(--d-sidebar-width) - var(--d-sidebar-row-horizontal-padding) * 2 + ); + + &:focus-within { + border-color: var(--tertiary); + outline: 2px solid var(--tertiary); + outline-offset: -2px; + } + + &__input[type="text"] { + border: 0; + margin-bottom: 0; + width: 50px; + height: 2em; + &:focus-within { + outline: 0; + } + width: calc(100% - 2em); + } + + &__clear { + width: 2em; + height: 2em; + color: var(--primary-medium); + background-color: var(--secondary); + } +} +.sidebar-no-results { + margin: 0.5em var(--d-sidebar-row-horizontal-padding) 0 + var(--d-sidebar-row-horizontal-padding); + &__title { + font-weight: bold; + } +} +.sidebar-no-results { + display: block; +} +.sidebar-section-wrapper + .sidebar-no-results { + display: none; +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0da2b8c827b..8a13f510652 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4243,6 +4243,7 @@ en: user_profile_menu: "%{shortcut} Open user menu" show_incoming_updated_topics: "%{shortcut} Show updated topics" search: "%{shortcut} Search" + filter_sidebar: "%{shortcut} Filter sidebar" help: "%{shortcut} Open keyboard help" dismiss_new: "%{shortcut} Dismiss New" dismiss_topics: "%{shortcut} Dismiss Topics" @@ -4677,6 +4678,11 @@ en: panels: forum: label: Forum + filter: "Filter" + clear_filter: "Clear filter" + no_results: + title: "No results" + description: "We couldn’t find anything matching ‘%{filter}’" welcome_topic_banner: title: "Create your Welcome Topic" diff --git a/spec/system/admin_sidebar_navigation_spec.rb b/spec/system/admin_sidebar_navigation_spec.rb index 1f4a4ed1964..160517f9832 100644 --- a/spec/system/admin_sidebar_navigation_spec.rb +++ b/spec/system/admin_sidebar_navigation_spec.rb @@ -5,6 +5,7 @@ describe "Admin Revamp | Sidebar Navigation", type: :system do let(:sidebar) { PageObjects::Components::NavigationMenu::Sidebar.new } let(:sidebar_dropdown) { PageObjects::Components::SidebarHeaderDropdown.new } + let(:filter) { PageObjects::Components::Filter.new } before do SiteSetting.admin_sidebar_enabled_groups = Group::AUTO_GROUPS[:admins] @@ -60,4 +61,34 @@ describe "Admin Revamp | Sidebar Navigation", type: :system do expect(sidebar).to have_no_section("admin-nav-section-root") end end + + it "allows links to be filtered" do + visit("/admin") + all_links_count = page.all(".sidebar-section-link-content-text").count + + links = page.all(".sidebar-section-link-content-text") + expect(links.count).to eq(all_links_count) + expect(page).to have_no_css(".sidebar-no-results") + + filter.filter("ie") + links = page.all(".sidebar-section-link-content-text") + expect(links.count).to eq(2) + expect(links.map(&:text)).to eq(["Preview Summary", "User Fields"]) + expect(page).to have_no_css(".sidebar-no-results") + + filter.filter("ieeee") + expect(page).to have_no_css(".sidebar-section-link-content-text") + expect(page).to have_css(".sidebar-no-results") + + filter.clear + links = page.all(".sidebar-section-link-content-text") + expect(links.count).to eq(all_links_count) + expect(page).to have_no_css(".sidebar-no-results") + + # When match section title, display all links + filter.filter("Backups") + links = page.all(".sidebar-section-link-content-text") + expect(links.count).to eq(2) + expect(links.map(&:text)).to eq(%w[Backups Logs]) + 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 00000000000..3bcdd671e4a --- /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