DEV: Add new experimental admin UI route and sidebar (#23952)

This commit adds a new admin UI under the route `/admin-revamp`, which is
only accessible if the user is in a group defined by the new `enable_experimental_admin_ui_groups` site setting. It
also adds a special `admin` sidebar panel that is shown instead of the `main`
forum one when the admin is in this area.

![image](https://github.com/discourse/discourse/assets/920448/fa0f25e1-e178-4d94-aa5f-472fd3efd787)

We also add an "Admin Revamp" sidebar link to the community section, which
will only appear if the user is in the setting group:

![image](https://github.com/discourse/discourse/assets/920448/ec05ca8b-5a54-442b-ba89-6af35695c104)

Within this there are subroutes defined like `/admin-revamp/config/:area`,
these areas could contain any UI imaginable, this is just laying down an
initial idea of the structure and how the sidebar will work. Sidebar links are
currently hardcoded.

Some other changes:

* Changed the `main` and `chat` panels sidebar panel keys to use exported const values for reuse
* Allowed custom sidebar sections to hide their headers with the `hideSectionHeader` option
* Add a `groupSettingArray` setting on `this.siteSettings` in JS, which accepts a group site setting name
  and splits it by `|` then converts the items in the array to integers, similar to the `_map` magic for ruby
  group site settings
* Adds a `hidden` option for sidebar panels which prevents them from showing in separated mode and prevents
  the switch button from being shown

---------

Co-authored-by: Krzysztof Kotlarek <kotlarek.krzysztof@gmail.com>
This commit is contained in:
Martin Brennan 2023-10-19 14:23:41 +10:00 committed by GitHub
parent 47b2667099
commit 9ef3a18ce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 606 additions and 23 deletions

View File

@ -0,0 +1,31 @@
import Controller from "@ember/controller";
import { inject as service } from "@ember/service";
import { dasherize } from "@ember/string";
import discourseComputed from "discourse-common/utils/decorators";
export default class AdminRevampController extends Controller {
@service router;
@discourseComputed("router._router.currentPath")
adminContentsClassName(currentPath) {
let cssClasses = currentPath
.split(".")
.filter((segment) => {
return (
segment !== "index" &&
segment !== "loading" &&
segment !== "show" &&
segment !== "admin"
);
})
.map(dasherize)
.join(" ");
// this is done to avoid breaking css customizations
if (cssClasses.includes("dashboard")) {
cssClasses = `${cssClasses} dashboard-next`;
}
return cssClasses;
}
}

View File

@ -0,0 +1,10 @@
import Route from "@ember/routing/route";
import { inject as service } from "@ember/service";
export default class AdminRevampConfigAreaRoute extends Route {
@service router;
async model(params) {
return { area: params.area };
}
}

View File

@ -0,0 +1,6 @@
import Route from "@ember/routing/route";
import { inject as service } from "@ember/service";
export default class AdminRevampConfigRoute extends Route {
@service router;
}

View File

@ -0,0 +1,6 @@
import Route from "@ember/routing/route";
import { inject as service } from "@ember/service";
export default class AdminRevampLobbyRoute extends Route {
@service router;
}

View File

@ -0,0 +1,40 @@
import { inject as service } from "@ember/service";
import DiscourseURL from "discourse/lib/url";
import DiscourseRoute from "discourse/routes/discourse";
import { ADMIN_PANEL, MAIN_PANEL } from "discourse/services/sidebar-state";
import I18n from "discourse-i18n";
export default class AdminRoute extends DiscourseRoute {
@service siteSettings;
@service currentUser;
@service sidebarState;
titleToken() {
return I18n.t("admin_title");
}
activate() {
if (
!this.currentUser.isInAnyGroups(
this.siteSettings.groupSettingArray(
"enable_experimental_admin_ui_groups"
)
)
) {
return DiscourseURL.redirectTo("/admin");
}
this.sidebarState.setPanel(ADMIN_PANEL);
this.sidebarState.setSeparatedMode();
this.sidebarState.hideSwitchPanelButtons();
this.controllerFor("application").setProperties({
showTop: false,
});
}
deactivate() {
this.controllerFor("application").set("showTop", true);
this.sidebarState.setPanel(MAIN_PANEL);
}
}

View File

@ -211,4 +211,14 @@ export default function () {
}
);
});
// EXPERIMENTAL: These admin routes are hidden behind an `enable_experimental_admin_ui_groups`
// site setting and are subject to constant change.
this.route("admin-revamp", { resetNamespace: true }, function () {
this.route("lobby", { path: "/" }, function () {});
this.route("config", { path: "config" }, function () {
this.route("area", { path: "/:area" });
});
});
}

View File

@ -0,0 +1,3 @@
<div class="admin-revamp__config-area">
Config Area ({{@model.area}})
</div>

View File

@ -0,0 +1,5 @@
<div class="admin-revamp__config">
Config
{{outlet}}
</div>

View File

@ -0,0 +1 @@
Admin Revamp Lobby

View File

@ -0,0 +1,12 @@
{{hide-application-footer}}
<AdminWrapper @class="container">
<div class="row">
<div class="full-width">
<div class="boxed white admin-content">
<div class="admin-contents {{this.adminContentsClassName}}">
{{outlet}}
</div>
</div>
</div>
</div>
</AdminWrapper>

View File

@ -31,7 +31,7 @@ export default class Sidebar extends Component {
}
return this.sidebarState.panels.filter(
(panel) => panel !== this.sidebarState.currentPanel
(panel) => panel !== this.sidebarState.currentPanel && !panel.hidden
);
}

View File

@ -7,6 +7,7 @@
@willDestroy={{this.section.willDestroy}}
@collapsable={{@collapsable}}
@displaySection={{this.section.displaySection}}
@hideSectionHeader={{this.section.hideSectionHeader}}
>
{{#each this.section.links as |link|}}

View File

@ -6,7 +6,10 @@ export default class SidebarApiSections extends Component {
get sections() {
if (this.sidebarState.combinedMode) {
return this.sidebarState.panels.map((panel) => panel.sections).flat();
return this.sidebarState.panels
.filter((panel) => !panel.hidden)
.map((panel) => panel.sections)
.flat();
} else {
return this.sidebarState.currentPanel.sections;
}

View File

@ -0,0 +1,176 @@
import {
addSidebarPanel,
addSidebarSection,
} from "discourse/lib/sidebar/custom-sections";
import { ADMIN_PANEL } from "discourse/services/sidebar-state";
function defineAdminSectionLink(BaseCustomSidebarSectionLink) {
const SidebarAdminSectionLink = class extends BaseCustomSidebarSectionLink {
constructor({ adminSidebarNavLink }) {
super(...arguments);
this.adminSidebarNavLink = adminSidebarNavLink;
}
get name() {
return this.adminSidebarNavLink.name;
}
get classNames() {
return "admin-sidebar-nav-link";
}
get route() {
return this.adminSidebarNavLink.route;
}
get models() {
return this.adminSidebarNavLink.routeModels;
}
get text() {
return this.adminSidebarNavLink.text;
}
get prefixType() {
return "icon";
}
get prefixValue() {
return this.adminSidebarNavLink.icon;
}
get title() {
return this.adminSidebarNavLink.text;
}
};
return SidebarAdminSectionLink;
}
function defineAdminSection(
adminNavSectionData,
BaseCustomSidebarSection,
adminSectionLinkClass
) {
const AdminNavSection = class extends BaseCustomSidebarSection {
constructor() {
super(...arguments);
this.adminNavSectionData = adminNavSectionData;
this.hideSectionHeader = adminNavSectionData.hideSectionHeader;
}
get sectionLinks() {
return this.adminNavSectionData.links;
}
get name() {
return `admin-nav-section-${this.adminNavSectionData.name}`;
}
get title() {
return this.adminNavSectionData.text;
}
get text() {
return this.adminNavSectionData.text;
}
get links() {
return this.sectionLinks.map(
(sectionLinkData) =>
new adminSectionLinkClass({ adminSidebarNavLink: sectionLinkData })
);
}
get displaySection() {
return true;
}
};
return AdminNavSection;
}
export default {
initialize(owner) {
this.currentUser = owner.lookup("service:currentUser");
if (!this.currentUser?.staff) {
return;
}
addSidebarPanel(
(BaseCustomSidebarPanel) =>
class AdminSidebarPanel extends BaseCustomSidebarPanel {
key = ADMIN_PANEL;
hidden = true;
}
);
let adminSectionLinkClass = null;
// HACK: This is just an example, we need a better way of defining this data.
const adminNavSections = [
{
text: "",
name: "root",
hideSectionHeader: true,
links: [
{
name: "Back to Forum",
route: "discovery.latest",
text: "Back to Forum",
icon: "arrow-left",
},
{
name: "Lobby",
route: "admin-revamp.lobby",
text: "Lobby",
icon: "home",
},
{
name: "legacy",
route: "admin",
text: "Legacy Admin",
icon: "wrench",
},
],
},
{
text: "Community",
name: "community",
links: [
{
name: "Item 1",
route: "admin-revamp.config.area",
routeModels: [{ area: "item-1" }],
text: "Item 1",
},
{
name: "Item 2",
route: "admin-revamp.config.area",
routeModels: [{ area: "item-2" }],
text: "Item 2",
},
],
},
];
adminNavSections.forEach((adminNavSectionData) => {
addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
// We only want to define the link class once even though we have many different sections.
adminSectionLinkClass =
adminSectionLinkClass ||
defineAdminSectionLink(BaseCustomSidebarSectionLink);
return defineAdminSection(
adminNavSectionData,
BaseCustomSidebarSection,
adminSectionLinkClass
);
},
ADMIN_PANEL
);
});
},
};

View File

@ -136,7 +136,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api";
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "1.14.0";
export const PLUGIN_API_VERSION = "1.15.0";
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
function canModify(klass, type, resolverName, changes) {
@ -2207,6 +2207,14 @@ class PluginApi {
this._lookupContainer("service:sidebar-state")?.setPanel(name);
}
/**
* EXPERIMENTAL. Do not use.
* Support for getting the current Sidebar panel.
*/
getSidebarPanel() {
return this._lookupContainer("service:sidebar-state")?.currentPanel;
}
/**
* EXPERIMENTAL. Do not use.
* Set combined sidebar section mode. In this mode, sections from all panels are displayed together.

View File

@ -4,6 +4,15 @@
export default class BaseCustomSidebarPanel {
sections = [];
/**
* @returns {boolean} Controls whether the panel is hidden, which means that
* it will not show up in combined sidebar mode, and its switch button will
* never show either.
*/
get hidden() {
return false;
}
/**
* @returns {string} Identifier for sidebar panel
*/
@ -12,24 +21,24 @@ export default class BaseCustomSidebarPanel {
}
/**
* @returns {string} Text for the switch button
* @returns {string} Text for the switch button. Obsolete when panel is hidden.
*/
get switchButtonLabel() {
this.#notImplemented();
this.hidden || this.#notImplemented();
}
/**
* @returns {string} Icon for the switch button
* @returns {string} Icon for the switch button. Obsolete when panel is hidden.
*/
get switchButtonIcon() {
this.#notImplemented();
this.hidden || this.#notImplemented();
}
/**
* @returns {string} Default path to panel
* @returns {string} Default path to panel. Obsolete when panel is hidden.
*/
get switchButtonDefaultUrl() {
this.#notImplemented();
this.hidden || this.#notImplemented();
}
#notImplemented() {

View File

@ -12,6 +12,7 @@ import {
secondaryCustomSectionLinks,
} from "discourse/lib/sidebar/custom-community-section-links";
import SectionLink from "discourse/lib/sidebar/section-link";
import AdminRevampSectionLink from "discourse/lib/sidebar/user/community-section/admin-revamp-section-link";
import AdminSectionLink from "discourse/lib/sidebar/user/community-section/admin-section-link";
import MyPostsSectionLink from "discourse/lib/sidebar/user/community-section/my-posts-section-link";
import ReviewSectionLink from "discourse/lib/sidebar/user/community-section/review-section-link";
@ -25,6 +26,7 @@ const SPECIAL_LINKS_MAP = {
"/review": ReviewSectionLink,
"/badges": BadgesSectionLink,
"/admin": AdminSectionLink,
"/admin-revamp": AdminRevampSectionLink,
"/g": GroupsSectionLink,
};

View File

@ -0,0 +1,45 @@
import { inject as service } from "@ember/service";
import BaseSectionLink from "discourse/lib/sidebar/base-community-section-link";
import I18n from "discourse-i18n";
export default class AdminRevampSectionLink extends BaseSectionLink {
@service siteSettings;
get name() {
return "admin-revamp";
}
get route() {
return "admin-revamp";
}
get title() {
return I18n.t("sidebar.sections.community.links.admin.content");
}
get text() {
return I18n.t(
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
{ defaultValue: this.overridenName }
);
}
get shouldDisplay() {
if (!this.currentUser) {
return false;
}
return (
this.currentUser.staff &&
this.currentUser.isInAnyGroups(
this.siteSettings.groupSettingArray(
"enable_experimental_admin_ui_groups"
)
)
);
}
get defaultPrefixValue() {
return "star";
}
}

View File

@ -8,7 +8,8 @@ import {
const COMBINED_MODE = "combined";
const SEPARATED_MODE = "separated";
const MAIN_PANEL = "main";
export const MAIN_PANEL = "main";
export const ADMIN_PANEL = "admin";
@disableImplicitInjections
export default class SidebarState extends Service {

View File

@ -7,6 +7,21 @@ export default class SiteSettingsService {
static isServiceFactory = true;
static create() {
return new TrackedObject(PreloadStore.get("siteSettings"));
const settings = new TrackedObject(PreloadStore.get("siteSettings"));
settings.groupSettingArray = (groupSetting) => {
const setting = settings[groupSetting];
if (!setting) {
return [];
}
return setting
.toString()
.split("|")
.filter(Boolean)
.map((groupId) => parseInt(groupId, 10));
};
return settings;
}
}

View File

@ -1084,4 +1084,118 @@ acceptance("Sidebar - Plugin API", function (needs) {
await visit("/");
assert.dom(".sidebar__panel-switch-button").exists();
});
test("New hidden custom sidebar panel", async function (assert) {
withPluginApi(PLUGIN_API_VERSION, (api) => {
api.addSidebarPanel((BaseCustomSidebarPanel) => {
const AdminSidebarPanel = class extends BaseCustomSidebarPanel {
get key() {
return "admin-panel";
}
get hidden() {
return true;
}
};
return AdminSidebarPanel;
});
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
return class extends BaseCustomSidebarSection {
get name() {
return "test-admin-section";
}
get text() {
return "test admin section";
}
get actionsIcon() {
return "cog";
}
get links() {
return [
new (class extends BaseCustomSidebarSectionLink {
get name() {
return "admin-link";
}
get classNames() {
return "my-class-name";
}
get route() {
return "topic";
}
get models() {
return ["some-slug", 1];
}
get title() {
return "admin link";
}
get text() {
return "admin link";
}
get prefixType() {
return "icon";
}
get prefixValue() {
return "cog";
}
get prefixColor() {
return "FF0000";
}
get prefixBadge() {
return "lock";
}
get suffixType() {
return "icon";
}
get suffixValue() {
return "circle";
}
get suffixCSSClass() {
return "unread";
}
})(),
];
}
};
},
"admin-panel"
);
api.setSidebarPanel("admin-panel");
api.setSeparatedSidebarMode();
});
await visit("/");
assert.strictEqual(
query(
".sidebar-section[data-section-name='test-admin-section'] .sidebar-section-header-text"
).textContent.trim(),
"test admin section",
"displays header with correct text"
);
withPluginApi(PLUGIN_API_VERSION, (api) => {
api.setSidebarPanel("main-panel");
api.setCombinedSidebarMode();
});
await visit("/");
assert.dom(".sidebar__panel-switch-button").doesNotExist();
assert
.dom(".sidebar-section[data-section-name='test-admin-section']")
.doesNotExist();
});
});

View File

@ -1052,3 +1052,6 @@ a.inline-editable-field {
@import "common/admin/admin_intro";
@import "common/admin/admin_emojis";
@import "common/admin/mini_profiler";
// EXPERIMENTAL: Revamped admin styles, probably can be split up later down the line.
@import "common/admin/admin_revamp";

View File

@ -0,0 +1,12 @@
.admin-revamp {
&__config {
padding: 1em;
background-color: var(--primary-low);
}
&__config-area {
padding: 1em;
margin: 1em 0;
background-color: var(--primary-very-low);
}
}

View File

@ -22,6 +22,12 @@ class SidebarUrl < ActiveRecord::Base
},
{ name: "Review", path: "/review", icon: "flag", segment: SidebarUrl.segments["primary"] },
{ name: "Admin", path: "/admin", icon: "wrench", segment: SidebarUrl.segments["primary"] },
{
name: "Admin Revamp",
path: "/admin-revamp",
icon: "star",
segment: SidebarUrl.segments["primary"],
},
{ name: "Users", path: "/u", icon: "users", segment: SidebarUrl.segments["secondary"] },
{
name: "About",

View File

@ -100,6 +100,14 @@ Discourse::Application.routes.draw do
get "wizard/steps/:id" => "wizard#index"
put "wizard/steps/:id" => "steps#update"
namespace :admin_revamp,
path: "admin-revamp",
module: "admin",
constraints: StaffConstraint.new do
get "" => "admin#index"
get "config/:area" => "admin#index"
end
namespace :admin, constraints: StaffConstraint.new do
get "" => "admin#index"

View File

@ -2178,6 +2178,14 @@ developer:
instrument_gc_stat_per_request:
default: false
hidden: true
enable_experimental_admin_ui_groups:
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
hidden: true
client: true
lazy_load_categories:
default: false
client: true

View File

@ -7,6 +7,13 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.15.0] - 2023-10-18
### Added
- Added `hidden` option to `addSidebarPanel`, this can be used to remove the panel from combined sidebar mode as well as hiding its switch button. Useful for cases where only one sidebar should be shown at a time regardless of other panels.
- Added `getSidebarPanel` function, which returns the current sidebar panel object for comparison.
## [1.14.0] - 2023-10-06
### Added

View File

@ -11,7 +11,10 @@ import getURL from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message";
import { initSidebarState } from "discourse/plugins/chat/discourse/lib/init-sidebar-state";
import {
CHAT_PANEL,
initSidebarState,
} from "discourse/plugins/chat/discourse/lib/init-sidebar-state";
export default {
name: "chat-sidebar",
@ -28,7 +31,7 @@ export default {
api.addSidebarPanel(
(BaseCustomSidebarPanel) =>
class ChatSidebarPanel extends BaseCustomSidebarPanel {
key = "chat";
key = CHAT_PANEL;
switchButtonLabel = I18n.t("sidebar.panels.chat.label");
switchButtonIcon = "d-chat";
switchButtonDefaultUrl = getURL("/chat");
@ -196,7 +199,7 @@ export default {
return SidebarChatChannelsSection;
},
"chat"
CHAT_PANEL
);
}

View File

@ -1,7 +1,14 @@
import { ADMIN_PANEL, MAIN_PANEL } from "discourse/services/sidebar-state";
import { getUserChatSeparateSidebarMode } from "discourse/plugins/chat/discourse/lib/get-user-chat-separate-sidebar-mode";
export const CHAT_PANEL = "chat";
export function initSidebarState(api, user) {
api.setSidebarPanel("main");
if (api.getSidebarPanel()?.key === ADMIN_PANEL) {
return;
}
api.setSidebarPanel(MAIN_PANEL);
const chatSeparateSidebarMode = getUserChatSeparateSidebarMode(user);
if (chatSeparateSidebarMode.fullscreen) {

View File

@ -6,7 +6,10 @@ import { scrollTop } from "discourse/mixins/scroll-top";
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n";
import { getUserChatSeparateSidebarMode } from "discourse/plugins/chat/discourse/lib/get-user-chat-separate-sidebar-mode";
import { initSidebarState } from "discourse/plugins/chat/discourse/lib/init-sidebar-state";
import {
CHAT_PANEL,
initSidebarState,
} from "discourse/plugins/chat/discourse/lib/init-sidebar-state";
export default class ChatRoute extends DiscourseRoute {
@service chat;
@ -62,7 +65,7 @@ export default class ChatRoute extends DiscourseRoute {
activate() {
withPluginApi("1.8.0", (api) => {
api.setSidebarPanel("chat");
api.setSidebarPanel(CHAT_PANEL);
const chatSeparateSidebarMode = getUserChatSeparateSidebarMode(
this.currentUser

View File

@ -4,6 +4,7 @@ import KeyValueStore from "discourse/lib/key-value-store";
import { withPluginApi } from "discourse/lib/plugin-api";
import { defaultHomepage } from "discourse/lib/utilities";
import Site from "discourse/models/site";
import { MAIN_PANEL } from "discourse/services/sidebar-state";
import getURL from "discourse-common/lib/get-url";
import { getUserChatSeparateSidebarMode } from "discourse/plugins/chat/discourse/lib/get-user-chat-separate-sidebar-mode";
@ -60,7 +61,7 @@ export default class ChatStateManager extends Service {
didOpenDrawer(url = null) {
withPluginApi("1.8.0", (api) => {
if (getUserChatSeparateSidebarMode(this.currentUser).always) {
api.setSidebarPanel("main");
api.setSidebarPanel(MAIN_PANEL);
api.setSeparatedSidebarMode();
api.hideSidebarSwitchPanelButtons();
} else {
@ -81,7 +82,7 @@ export default class ChatStateManager extends Service {
didCloseDrawer() {
withPluginApi("1.8.0", (api) => {
api.setSidebarPanel("main");
api.setSidebarPanel(MAIN_PANEL);
const chatSeparateSidebarMode = getUserChatSeparateSidebarMode(
this.currentUser

View File

@ -68,9 +68,7 @@ export default class Chat extends Service {
return (
this.currentUser.staff ||
this.currentUser.isInAnyGroups(
(this.siteSettings.direct_message_enabled_groups || "11") // trust level 1 auto group
.split("|")
.map((groupId) => parseInt(groupId, 10))
this.siteSettings.groupSettingArray("direct_message_enabled_groups")
)
);
}

View File

@ -22,7 +22,18 @@ RSpec.describe SidebarSection do
expect(community_section.reload.title).to eq("Community")
expect(community_section.sidebar_section_links.all.map { |link| link.linkable.name }).to eq(
["Topics", "My Posts", "Review", "Admin", "Users", "About", "FAQ", "Groups", "Badges"],
[
"Topics",
"My Posts",
"Review",
"Admin",
"Admin Revamp",
"Users",
"About",
"FAQ",
"Groups",
"Badges",
],
)
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
describe "Admin Revamp | Sidebar Naviagion", type: :system do
fab!(:admin) { Fabricate(:admin) }
let(:sidebar_page) { PageObjects::Components::NavigationMenu::Sidebar.new }
before do
SiteSetting.enable_experimental_admin_ui_groups = Group::AUTO_GROUPS[:staff]
SidebarSection.find_by(section_type: "community").reset_community!
sign_in(admin)
end
it "navigates to the admin revamp from the sidebar" do
visit("/latest")
sidebar_page.click_section_link("Admin Revamp")
expect(page).to have_content("Admin Revamp Lobby")
end
end