FEATURE: filter admin sidebar (#25853)

Ability to filter admin sidebar. The filter can be cleared. In addition, it can be accessed with ctrl+/ shortcut
This commit is contained in:
Krzysztof Kotlarek 2024-02-28 12:15:02 +11:00 committed by GitHub
parent ffce2dd04f
commit 8b5204579c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 298 additions and 47 deletions

View File

@ -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"],

View File

@ -1,4 +1,5 @@
<Sidebar::Section
{{#if this.shouldDisplay}}
<Sidebar::Section
@sectionName={{this.section.name}}
@headerLinkText={{this.section.text}}
@headerLinkTitle={{this.section.title}}
@ -8,9 +9,8 @@
@collapsable={{@collapsable}}
@displaySection={{this.section.displaySection}}
@hideSectionHeader={{this.section.hideSectionHeader}}
>
{{#each this.section.links as |link|}}
>
{{#each this.filteredLinks key="name" as |link|}}
<Sidebar::SectionLink
@linkName={{link.name}}
@linkClass={{link.classNames}}
@ -42,4 +42,5 @@
}}
/>
{{/each}}
</Sidebar::Section>
</Sidebar::Section>
{{/if}}

View File

@ -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);
});
}
}

View File

@ -1,6 +1,8 @@
<Sidebar::Filter />
{{#each this.sections as |sectionConfig|}}
<Sidebar::ApiSection
@sectionConfig={{sectionConfig}}
@collapsable={{@collapsable}}
/>
{{/each}}
<Sidebar::FilterNoResults />

View File

@ -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;
}
<template>
{{#if this.shouldDisplay}}
<div class="sidebar-no-results">
<div class="sidebar-no-results__title">{{i18n
"sidebar.no_results.title"
}}</div>
<div class="sidebar-no-results__description">{{i18n
"sidebar.no_results.description"
filter=this.sidebarState.filter
}}</div>
</div>
{{/if}}
</template>
}

View File

@ -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();
}
<template>
{{#if this.shouldDisplay}}
<div class="sidebar-filter" {{willDestroy this.teardown}}>
<Input
class="sidebar-filter__input"
placeholder={{i18n "sidebar.filter"}}
@value={{this.sidebarState.filter}}
{{on "input" this.setFilter}}
/>
{{#if this.displayClearFilter}}
<DButton @action={{this.clearFilter}} class="sidebar-filter__clear">
{{dIcon "times"}}
</DButton>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@ -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")) {

View File

@ -255,4 +255,8 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel {
return defineAdminSection(adminNavSectionData);
});
}
get filterable() {
return true;
}
}

View File

@ -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";
}

View File

@ -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 = "";
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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 couldnt find anything matching %{filter}"
welcome_topic_banner:
title: "Create your Welcome Topic"

View File

@ -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

View File

@ -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