DEV: Untangle the admin sidebar from the sidebar code (#27640)

This commit is contained in:
Sérgio Saquetim 2024-07-05 13:11:15 -03:00 committed by GitHub
parent 640dccd224
commit b36cbc7d21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 270 additions and 181 deletions

View File

@ -1,99 +1,55 @@
import Component from "@glimmer/component";
import { getOwner, setOwner } from "@ember/owner";
import { service } from "@ember/service";
import Section from "./section";
import SectionLink from "./section-link";
export default class SidebarApiSection extends Component {
@service sidebarState;
const SidebarApiSection = <template>
{{#if @section.filtered}}
<Section
@sectionName={{@section.name}}
@headerLinkText={{@section.text}}
@headerLinkTitle={{@section.title}}
@headerActionsIcon={{@section.actionsIcon}}
@headerActions={{@section.actions}}
@willDestroy={{@section.willDestroy}}
@collapsable={{@collapsable}}
@displaySection={{@section.displaySection}}
@hideSectionHeader={{@section.hideSectionHeader}}
@collapsedByDefault={{@section.collapsedByDefault}}
>
{{#each @section.filteredLinks key="name" as |link|}}
<SectionLink
@linkName={{link.name}}
@linkClass={{link.classNames}}
@route={{link.route}}
@model={{link.model}}
@query={{link.query}}
@models={{link.models}}
@href={{link.href}}
@title={{link.title}}
@contentCSSClass={{link.contentCSSClass}}
@prefixColor={{link.prefixColor}}
@prefixBadge={{link.prefixBadge}}
@prefixType={{link.prefixType}}
@prefixValue={{link.prefixValue}}
@prefixCSSClass={{link.prefixCSSClass}}
@suffixType={{link.suffixType}}
@suffixValue={{link.suffixValue}}
@suffixCSSClass={{link.suffixCSSClass}}
@hoverType={{link.hoverType}}
@hoverValue={{link.hoverValue}}
@hoverAction={{link.hoverAction}}
@hoverTitle={{link.hoverTitle}}
@currentWhen={{link.currentWhen}}
@didInsert={{link.didInsert}}
@willDestroy={{link.willDestroy}}
@content={{link.text}}
@contentComponent={{component
link.contentComponent
status=link.contentComponentArgs
}}
/>
{{/each}}
</Section>
{{/if}}
</template>;
constructor() {
super(...arguments);
this.section = new this.args.sectionConfig();
setOwner(this.section, getOwner(this));
}
get shouldDisplay() {
return (
!this.sidebarState.currentPanel.filterable ||
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.sanitizedFilter)
) {
return this.section.links;
}
return this.section.links.filter((link) => {
return (
link.text
.toString()
.toLowerCase()
.match(this.sidebarState.sanitizedFilter) ||
link.keywords.navigation.some((keyword) =>
keyword.match(this.sidebarState.filter)
)
);
});
}
<template>
{{#if this.shouldDisplay}}
<Section
@sectionName={{this.section.name}}
@headerLinkText={{this.section.text}}
@headerLinkTitle={{this.section.title}}
@headerActionsIcon={{this.section.actionsIcon}}
@headerActions={{this.section.actions}}
@willDestroy={{this.section.willDestroy}}
@collapsable={{@collapsable}}
@displaySection={{this.section.displaySection}}
@hideSectionHeader={{this.section.hideSectionHeader}}
@collapsedByDefault={{this.section.collapsedByDefault}}
>
{{#each this.filteredLinks key="name" as |link|}}
<SectionLink
@linkName={{link.name}}
@linkClass={{link.classNames}}
@route={{link.route}}
@model={{link.model}}
@query={{link.query}}
@models={{link.models}}
@href={{link.href}}
@title={{link.title}}
@contentCSSClass={{link.contentCSSClass}}
@prefixColor={{link.prefixColor}}
@prefixBadge={{link.prefixBadge}}
@prefixType={{link.prefixType}}
@prefixValue={{link.prefixValue}}
@prefixCSSClass={{link.prefixCSSClass}}
@suffixType={{link.suffixType}}
@suffixValue={{link.suffixValue}}
@suffixCSSClass={{link.suffixCSSClass}}
@hoverType={{link.hoverType}}
@hoverValue={{link.hoverValue}}
@hoverAction={{link.hoverAction}}
@hoverTitle={{link.hoverTitle}}
@currentWhen={{link.currentWhen}}
@didInsert={{link.didInsert}}
@willDestroy={{link.willDestroy}}
@content={{link.text}}
@contentComponent={{component
link.contentComponent
status=link.contentComponentArgs
}}
/>
{{/each}}
</Section>
{{/if}}
</template>
}
export default SidebarApiSection;

View File

@ -1,32 +1,89 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { getOwner, setOwner } from "@ember/owner";
import { service } from "@ember/service";
import AdminHeader from "./admin-header";
import ApiSection from "./api-section";
import FilterNoResults from "./filter-no-results";
import PanelHeader from "./panel-header";
export default class SidebarApiSections extends Component {
@service sidebarState;
get sections() {
let sectionConfigs;
if (this.sidebarState.combinedMode) {
return this.sidebarState.panels
sectionConfigs = this.sidebarState.panels
.filter((panel) => !panel.hidden)
.flatMap((panel) => panel.sections);
} else {
return this.sidebarState.currentPanel.sections;
sectionConfigs = this.sidebarState.currentPanel.sections;
}
return sectionConfigs.map((Section) => {
const SidebarSection = prepareSidebarSectionClass(Section);
const sectionInstance = new SidebarSection({
filterable:
!this.sidebarState.combinedMode &&
this.sidebarState.currentPanel.filterable,
sidebarState: this.sidebarState,
});
setOwner(sectionInstance, getOwner(this));
return sectionInstance;
});
}
get filteredSections() {
return this.sections.filter((section) => section.filtered);
}
<template>
<AdminHeader />
<PanelHeader @sections={{this.filteredSections}} />
{{#each this.sections as |sectionConfig|}}
<ApiSection
@sectionConfig={{sectionConfig}}
@collapsable={{@collapsable}}
/>
{{#each this.filteredSections as |section|}}
<ApiSection @section={{section}} @collapsable={{@collapsable}} />
{{/each}}
<FilterNoResults />
</template>
}
// extends the class provided for the section to add functionality we don't want to be overridable when defining custom
// sections using the plugin API, like for example the filtering capabilities
function prepareSidebarSectionClass(Section) {
return class extends Section {
constructor({ filterable, sidebarState }) {
super();
this.filterable = filterable;
this.sidebarState = sidebarState;
}
@cached
get filteredLinks() {
if (!this.filterable || !this.sidebarState.filter) {
return this.links;
}
if (this.text.toLowerCase().match(this.sidebarState.sanitizedFilter)) {
return this.links;
}
return this.links.filter((link) => {
return (
link.text
.toString()
.toLowerCase()
.match(this.sidebarState.sanitizedFilter) ||
link.keywords.navigation.some((keyword) =>
keyword.match(this.sidebarState.filter)
)
);
});
}
get filtered() {
return !this.filterable || this.filteredLinks?.length > 0;
}
};
}

View File

@ -1,28 +1,17 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { ADMIN_PANEL } from "discourse/lib/sidebar/panels";
import { defaultHomepage } from "discourse/lib/utilities";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
export default class BackToForum extends Component {
@service sidebarState;
const BackToForum = <template>
<LinkTo
@route="discovery.{{(defaultHomepage)}}"
class="sidebar-sections__back-to-forum"
>
{{icon "arrow-left"}}
get shouldDisplay() {
return this.sidebarState.isCurrentPanel(ADMIN_PANEL);
}
<span>{{i18n "sidebar.back_to_forum"}}</span>
</LinkTo>
</template>;
<template>
{{#if this.shouldDisplay}}
<LinkTo
@route="discovery.{{(defaultHomepage)}}"
class="sidebar-sections__back-to-forum"
>
{{icon "arrow-left"}}
<span>{{i18n "admin.back_to_forum"}}</span>
</LinkTo>
{{/if}}
</template>
}
export default BackToForum;

View File

@ -1,32 +1,21 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import i18n from "discourse-common/helpers/i18n";
import getURL from "discourse-common/lib/get-url";
import I18n from "discourse-i18n";
export default class FilterNoResults 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;
return (
this.sidebarState.currentPanel.filterable &&
!!(this.args.sections?.length === 0)
);
}
get noResultsDescription() {
const params = {
filter: this.sidebarState.filter,
settings_filter_url: getURL(
`/admin/site_settings/category/all_results?filter=${this.sidebarState.filter}`
),
user_list_filter_url: getURL(
`/admin/users/list/active?username=${this.sidebarState.filter}`
),
};
return htmlSafe(I18n.t("sidebar.no_results.description", params));
return this.sidebarState.currentPanel.filterNoResultsDescription(
this.sidebarState.filter
);
}
<template>
@ -35,9 +24,11 @@ export default class FilterNoResults extends Component {
<h4 class="sidebar-no-results__title">{{i18n
"sidebar.no_results.title"
}}</h4>
<p
class="sidebar-no-results__description"
>{{this.noResultsDescription}}</p>
{{#if this.noResultsDescription}}
<p class="sidebar-no-results__description">
{{this.noResultsDescription}}
</p>
{{/if}}
</div>
{{/if}}
</template>

View File

@ -5,7 +5,7 @@ import DButton from "discourse/components/d-button";
import SidebarSectionForm from "discourse/components/modal/sidebar-section-form";
import PluginOutlet from "discourse/components/plugin-outlet";
import routeAction from "discourse/helpers/route-action";
import { ADMIN_PANEL } from "discourse/lib/sidebar/panels";
import { MAIN_PANEL } from "discourse/lib/sidebar/panels";
export default class SidebarFooter extends Component {
@service capabilities;
@ -16,7 +16,7 @@ export default class SidebarFooter extends Component {
@service sidebarState;
get showManageSectionsButton() {
return this.currentUser && !this.sidebarState.isCurrentPanel(ADMIN_PANEL);
return this.currentUser && this.sidebarState.isCurrentPanel(MAIN_PANEL);
}
get showToggleMobileButton() {

View File

@ -1,28 +1,29 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { ADMIN_PANEL } from "discourse/lib/sidebar/panels";
import BackToForum from "./back-to-forum";
import Filter from "./filter";
import FilterNoResults from "./filter-no-results";
import ToggleAllSections from "./toggle-all-sections";
export default class AdminHeader extends Component {
export default class PanelHeader extends Component {
@service sidebarState;
get shouldDisplay() {
return this.sidebarState.isCurrentPanel(ADMIN_PANEL);
return this.sidebarState.currentPanel.displayHeader;
}
<template>
{{#if this.shouldDisplay}}
<div class="sidebar-admin-header">
<div class="sidebar-admin-header__row">
<div class="sidebar-panel-header">
<div class="sidebar-panel-header__row">
<BackToForum />
<ToggleAllSections @sections={{@sections}} />
</div>
<div class="sidebar-admin-header__row">
<div class="sidebar-panel-header__row">
<Filter />
</div>
</div>
<FilterNoResults @sections={{@sections}} />
{{/if}}
</template>
}

View File

@ -5,7 +5,7 @@ const SidebarSectionHeader = <template>
<DButton
@title="sidebar.toggle_section"
@action={{@toggleSectionDisplay}}
aria-controls={{@sidebarSectionContentID}}
aria-controls={{@sidebarSectionContentId}}
aria-expanded={{if @isExpanded "true" "false"}}
class="sidebar-section-header sidebar-section-header-collapsable btn-transparent"
>

View File

@ -5,6 +5,10 @@ import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import {
getCollapsedSidebarSectionKey,
getSidebarSectionContentId,
} from "discourse/lib/sidebar/helpers";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
@ -16,8 +20,10 @@ export default class SidebarSection extends Component {
@service keyValueStore;
@service sidebarState;
sidebarSectionContentID = `sidebar-section-content-${this.args.sectionName}`;
collapsedSidebarSectionKey = `sidebar-section-${this.args.sectionName}-collapsed`;
sidebarSectionContentId = getSidebarSectionContentId(this.args.sectionName);
collapsedSidebarSectionKey = getCollapsedSidebarSectionKey(
this.args.sectionName
);
willDestroy() {
super.willDestroy(...arguments);
@ -116,7 +122,7 @@ export default class SidebarSection extends Component {
<div class="sidebar-section-header-wrapper sidebar-row">
<SectionHeader
@collapsable={{@collapsable}}
@sidebarSectionContentID={{this.sidebarSectionContentID}}
@sidebarSectionContentId={{this.sidebarSectionContentId}}
@toggleSectionDisplay={{this.toggleSectionDisplay}}
@isExpanded={{this.displaySectionContent}}
>
@ -174,7 +180,7 @@ export default class SidebarSection extends Component {
{{#if this.displaySectionContent}}
<ul
id={{this.sidebarSectionContentID}}
id={{this.sidebarSectionContentId}}
class="sidebar-section-content"
>
{{yield}}

View File

@ -2,24 +2,30 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { ADMIN_NAV_MAP } from "discourse/lib/sidebar/admin-nav-map";
import { getCollapsedSidebarSectionKey } from "discourse/lib/sidebar/helpers";
export default class ToggleAllSections extends Component {
@service sidebarState;
@service keyValueStore;
get collapsableSections() {
return this.args.sections.filter(
(section) => section.displaySection && !section.hideSectionHeader
);
}
get allSectionsExpanded() {
return ADMIN_NAV_MAP.every((adminNav) => {
return this.collapsableSections.every((section) => {
return !this.sidebarState.collapsedSections.has(
`sidebar-section-${this.sidebarState.currentPanel.key}-${adminNav.name}-collapsed`
getCollapsedSidebarSectionKey(section.name)
);
});
}
get title() {
return this.allSectionsExpanded
? "admin.collapse_all_sections"
: "admin.expand_all_sections";
? "sidebar.collapse_all_sections"
: "sidebar.expand_all_sections";
}
get icon() {
@ -30,12 +36,11 @@ export default class ToggleAllSections extends Component {
toggleAllSections() {
const collapse = this.allSectionsExpanded;
ADMIN_NAV_MAP.forEach((adminNav) => {
const key = `${this.sidebarState.currentPanel.key}-${adminNav.name}`;
this.collapsableSections.forEach((section) => {
if (collapse) {
this.sidebarState.collapseSection(key);
this.sidebarState.collapseSection(section.name);
} else {
this.sidebarState.expandSection(key);
this.sidebarState.expandSection(section.name);
}
});
}

View File

@ -1,4 +1,5 @@
import { cached } from "@glimmer/tracking";
import { htmlSafe } from "@ember/template";
import PreloadStore from "discourse/lib/preload-store";
import { ADMIN_NAV_MAP } from "discourse/lib/sidebar/admin-nav-map";
import BaseCustomSidebarPanel from "discourse/lib/sidebar/base-custom-sidebar-panel";
@ -6,6 +7,7 @@ import BaseCustomSidebarSection from "discourse/lib/sidebar/base-custom-sidebar-
import BaseCustomSidebarSectionLink from "discourse/lib/sidebar/base-custom-sidebar-section-link";
import { ADMIN_PANEL } from "discourse/lib/sidebar/panels";
import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
import getURL from "discourse-common/lib/get-url";
import I18n from "discourse-i18n";
let additionalAdminSidebarSectionLinks = {};
@ -81,6 +83,7 @@ class SidebarAdminSectionLink extends BaseCustomSidebarSectionLink {
return this.adminSidebarNavLink.route;
}
get keywords() {
return (
this.adminSidebarStateManager.keywords[this.adminSidebarNavLink.name] || {
@ -257,6 +260,7 @@ function installedPluginsLinkKeywords() {
export default class AdminSidebarPanel extends BaseCustomSidebarPanel {
key = ADMIN_PANEL;
hidden = true;
displayHeader = true;
@cached
get sections() {
@ -346,4 +350,18 @@ export default class AdminSidebarPanel extends BaseCustomSidebarPanel {
get filterable() {
return true;
}
filterNoResultsDescription(filter) {
const params = {
filter,
settings_filter_url: getURL(
`/admin/site_settings/category/all_results?filter=${filter}`
),
user_list_filter_url: getURL(
`/admin/users/list/active?username=${filter}`
),
};
return htmlSafe(I18n.t("sidebar.no_results.description", params));
}
}

View File

@ -43,6 +43,13 @@ export default class BaseCustomSidebarPanel {
this.hidden || this.#notImplemented();
}
/**
* @returns {boolean} Controls whether the panel will display a header
*/
get displayHeader() {
return false;
}
/**
* @returns {boolean} Controls whether the filter is shown
*/
@ -50,6 +57,17 @@ export default class BaseCustomSidebarPanel {
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;
}
#notImplemented() {
throw "not implemented";
}

View File

@ -15,3 +15,11 @@ export function hasDefaultSidebarCategories(siteSettings) {
export function hasDefaultSidebarTags(siteSettings) {
return siteSettings.default_navigation_menu_tags.length > 0;
}
export function getSidebarSectionContentId(name) {
return `sidebar-section-content-${name}`;
}
export function getCollapsedSidebarSectionKey(name) {
return `sidebar-section-${name}-collapsed`;
}

View File

@ -7,6 +7,7 @@ import {
currentPanelKey,
customPanels as panels,
} from "discourse/lib/sidebar/custom-sections";
import { getCollapsedSidebarSectionKey } from "discourse/lib/sidebar/helpers";
import {
COMBINED_MODE,
MAIN_PANEL,
@ -81,13 +82,15 @@ export default class SidebarState extends Service {
}
collapseSection(sectionKey) {
const collapsedSidebarSectionKey = `sidebar-section-${sectionKey}-collapsed`;
const collapsedSidebarSectionKey =
getCollapsedSidebarSectionKey(sectionKey);
this.keyValueStore.setItem(collapsedSidebarSectionKey, true);
this.collapsedSections.add(collapsedSidebarSectionKey);
}
expandSection(sectionKey) {
const collapsedSidebarSectionKey = `sidebar-section-${sectionKey}-collapsed`;
const collapsedSidebarSectionKey =
getCollapsedSidebarSectionKey(sectionKey);
this.keyValueStore.setItem(collapsedSidebarSectionKey, false);
this.collapsedSections.delete(collapsedSidebarSectionKey);
}

View File

@ -338,7 +338,7 @@
font-size: var(--font-down-1);
}
.sidebar-admin-header__row {
.sidebar-panel-header__row {
width: calc(320px - 2 * var(--d-sidebar-row-horizontal-padding));
}

View File

@ -373,11 +373,8 @@
.sidebar-no-results {
display: block;
}
.sidebar-section-wrapper + .sidebar-no-results {
display: none;
}
.sidebar-admin-header__row {
.sidebar-panel-header__row {
display: flex;
justify-content: space-between;
align-items: baseline;

View File

@ -4806,6 +4806,9 @@ en:
panels:
forum:
label: Forum
back_to_forum: "Back to Forum"
collapse_all_sections: "Collapse all sections"
expand_all_sections: "Expand all sections"
filter: "Filter..."
clear_filter: "Clear filter"
no_results:
@ -4926,10 +4929,7 @@ en:
admin:
title: "Discourse Admin"
moderator: "Moderator"
back_to_forum: "Back to Forum"
filter_reports: Filter reports
expand_all_sections: "Expand all sections"
collapse_all_sections: "Collapse all sections"
tags:
remove_muted_tags_from_latest:

View File

@ -31,6 +31,11 @@ describe "Admin Revamp | Sidebar Navigation", type: :system do
expect(sidebar).to have_no_section("admin-root")
end
it "displays the panel header" do
visit("/admin")
expect(sidebar).to have_panel_header
end
it "collapses sections by default" do
visit("/admin")
links = page.all(".sidebar-section-link-content-text")
@ -113,6 +118,27 @@ describe "Admin Revamp | Sidebar Navigation", type: :system do
expect(page).to have_no_css(".sidebar-no-results")
end
it "displays the no results description message correctly when the filter has no results" do
visit("/admin")
filter.filter("ieeee")
expect(page).to have_no_css(".sidebar-section-link-content-text")
expect(page).to have_css(".sidebar-no-results")
no_results_description = page.find(".sidebar-no-results__description")
expect(no_results_description.text).to eq(
"We couldnt find anything matching ieeee.\n\nDid you want to search site settings or the admin user list?",
)
expect(no_results_description).to have_link(
"search site settings",
href: "/admin/site_settings/category/all_results?filter=ieeee",
)
expect(no_results_description).to have_link(
"admin user list?",
href: "/admin/users/list/active?username=ieeee",
)
end
it "temporarily expands section when filter" do
visit("/admin")
links = page.all(".sidebar-section-link-content-text")

View File

@ -49,6 +49,14 @@ module PageObjects
find("#discourse-modal-title")
end
def has_panel_header?
page.has_css?(".sidebar-panel-header")
end
def has_no_panel_header?
page.has_no_css?(".sidebar-panel-header")
end
def toggle_all_sections
find(".sidebar-toggle-all-sections").click
end

View File

@ -136,4 +136,10 @@ describe "Viewing sidebar as logged in user", type: :system do
expect(sidebar).to have_all_tags_section_link
end
end
it "shouldn't display the panel header for the main sidebar" do
visit("/latest")
expect(sidebar).to be_visible
expect(sidebar).to have_no_panel_header
end
end