From d539884204b2b4e0b7a4018078d3fe771df6fe90 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 16 Nov 2023 10:38:36 -0800 Subject: [PATCH] web: continuing with the Sidebar I've finally reached a stage where I have a framework I can build upon, but what a pain in the posterior it was to get here. Keeping the entire navigation list within a single DOM is a solid idea, but porting from the original code to this proved to be unreasonably kludegy. Instead, I started from scratch, adding each step along the way, a sort of Transformation Priority Premise, and testing each step to make sure it was all behaving as expected. So far, so good. The remaining details are not trivial: I have to figure out how to express the different classes of actions, and get the third tier working, but at least the React version gives us hints. --- .../admin/AdminInterface/AdminInterface.ts | 1 - web/src/admin/AdminInterface/AdminSidebar.ts | 165 +++++------- web/src/elements/sidebar/Sidebar.ts | 21 +- web/src/elements/sidebar/SidebarItem.ts | 227 ---------------- web/src/elements/sidebar/SidebarItems.ts | 255 ++++++++++++++++++ web/src/user/UserInterface.ts | 2 - 6 files changed, 335 insertions(+), 336 deletions(-) delete mode 100644 web/src/elements/sidebar/SidebarItem.ts create mode 100644 web/src/elements/sidebar/SidebarItems.ts diff --git a/web/src/admin/AdminInterface/AdminInterface.ts b/web/src/admin/AdminInterface/AdminInterface.ts index 834c98f37..7b780506e 100644 --- a/web/src/admin/AdminInterface/AdminInterface.ts +++ b/web/src/admin/AdminInterface/AdminInterface.ts @@ -17,7 +17,6 @@ import "@goauthentik/elements/notifications/NotificationDrawer"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import "@goauthentik/elements/router/RouterOutlet"; import "@goauthentik/elements/sidebar/Sidebar"; -import "@goauthentik/elements/sidebar/SidebarItem"; import { CSSResult, TemplateResult, css, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index 2f973ca7e..991d5fa66 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -4,18 +4,40 @@ import { me } from "@goauthentik/common/users"; import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; import { AKElement } from "@goauthentik/elements/Base"; import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; +import "@goauthentik/elements/sidebar/Sidebar"; +import { SidebarAttributes, SidebarEntry, SidebarEventHandler } from "@goauthentik/elements/sidebar/SidebarItems"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; -import { spread } from "@open-wc/lit-helpers"; import { consume } from "@lit-labs/context"; import { msg, str } from "@lit/localize"; -import { TemplateResult, html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { map } from "lit/directives/map.js"; -import { AdminApi, CapabilitiesEnum, CoreApi, UiThemeEnum, Version } from "@goauthentik/api"; +import { ProvidersApi, TypeCreate } from "@goauthentik/api"; +import { AdminApi, CapabilitiesEnum, CoreApi, Version } from "@goauthentik/api"; import type { Config, SessionUser, UserSelf } from "@goauthentik/api"; +/** + * AdminSidebar + * + * Encapsulates the logic for the administration sidebar: what to show and, initially, when to show + * it. Rendering decisions are left to the sidebar itself. + */ + +type LocalSidebarEntry = [ + string | SidebarEventHandler | null, + string, + (SidebarAttributes | string[] | null)?, // eslint-disable-line + LocalSidebarEntry[]?, +]; + +const localToSidebarEntry = (l: LocalSidebarEntry): SidebarEntry => ({ + path: l[0], + label: l[1], + ...(l[2]? { attributes: Array.isArray(l[2]) ? { activeWhen: l[2] } : l[2] } : {}), + ...(l[3] ? { children: l[3].map(localToSidebarEntry) } : {}), +}); + @customElement("ak-admin-sidebar") export class AkAdminSidebar extends AKElement { @property({ type: Boolean, reflect: true }) @@ -27,6 +49,9 @@ export class AkAdminSidebar extends AKElement { @state() impersonation: UserSelf["username"] | null = null; + @state() + providerTypes: TypeCreate[] = []; + @consume({ context: authentikConfigContext }) public config!: Config; @@ -38,6 +63,9 @@ export class AkAdminSidebar extends AKElement { me().then((user: SessionUser) => { this.impersonation = user.original ? user.user.username : null; }); + new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => { + this.providerTypes = types; + }); this.toggleOpen = this.toggleOpen.bind(this); this.checkWidth = this.checkWidth.bind(this); } @@ -51,9 +79,7 @@ export class AkAdminSidebar extends AKElement { checkWidth() { // This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in // REMs. If that changes, this code will have to be adjusted as well. - const minWidth = - parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) * - parseFloat(getRootStyle("font-size")); + const minWidth = parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) * parseFloat(getRootStyle("font-size")); this.open = window.innerWidth >= minWidth; } @@ -75,19 +101,6 @@ export class AkAdminSidebar extends AKElement { super.disconnectedCallback(); } - render() { - return html` - - ${this.renderSidebarItems()} - - `; - } - updated() { // This is permissible as`:host.classList` is not one of the properties Lit uses as a // scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger @@ -98,26 +111,43 @@ export class AkAdminSidebar extends AKElement { this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed"); } - renderSidebarItems(): TemplateResult { - // The second attribute type is of string[] to help with the 'activeWhen' control, which was - // commonplace and singular enough to merit its own handler. - type SidebarEntry = [ - path: string | null, - label: string, - attributes?: Record | string[] | null, // eslint-disable-line - children?: SidebarEntry[], - ]; + get sidebarItems(): SidebarEntry[] { + const reload = () => + new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => { + window.location.reload(); + }); // prettier-ignore - const sidebarContent: SidebarEntry[] = [ - ["/if/user/", msg("User interface"), { "?isAbsoluteLink": true, "?highlight": true }], - [null, msg("Dashboards"), { "?expanded": true }, [ + const newVersionMessage: LocalSidebarEntry[] = this.version && this.version !== VERSION + ? [["https://goauthentik.io", msg("A newer version of the frontend is available."), { "?highlight": true }]] + : []; + + // prettier-ignore + const impersonationMessage: LocalSidebarEntry[] = this.impersonation + ? [[reload, msg(str`You're currently impersonating ${this.impersonation}. Click to stop.`)]] + : []; + + // prettier-ignore + const enterpriseMenu: LocalSidebarEntry[] = this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise) + ? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]] + : []; + + // prettier-ignore + const providerTypes: LocalSidebarEntry[] = this.providerTypes.map((ptype) => + ([`/core/providers;${encodeURIComponent(JSON.stringify({ search: ptype.modelName.replace(/provider$/, "") }))}`, ptype.name])); + + // prettier-ignore + const localSidebar: LocalSidebarEntry[] = [ + ...(newVersionMessage), + ...(impersonationMessage), + ["/if/user/", msg("User interface"), { isAbsoluteLink: true, highlight: true }], + [null, msg("Dashboards"), { expanded: true }, [ ["/administration/overview", msg("Overview")], ["/administration/dashboard/users", msg("User Statistics")], ["/administration/system-tasks", msg("System Tasks")]]], [null, msg("Applications"), null, [ ["/core/applications", msg("Applications"), [`^/core/applications/(?${SLUG_REGEX})$`]], - ["/core/providers", msg("Providers"), [`^/core/providers/(?${ID_REGEX})$`]], + ["/core/providers", msg("Providers"), [`^/core/providers/(?${ID_REGEX})$`], providerTypes], ["/outpost/outposts", msg("Outposts")]]], [null, msg("Events"), null, [ ["/events/log", msg("Logs"), [`^/events/log/(?${UUID_REGEX})$`]], @@ -142,73 +172,14 @@ export class AkAdminSidebar extends AKElement { [null, msg("System"), null, [ ["/core/tenants", msg("Tenants")], ["/crypto/certificates", msg("Certificates")], - ["/outpost/integrations", msg("Outpost Integrations")]]] + ["/outpost/integrations", msg("Outpost Integrations")]]], + ...(enterpriseMenu) ]; - // Typescript requires the type here to correctly type the recursive path - type SidebarRenderer = (_: SidebarEntry) => TemplateResult; - - const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => { - const properties = Array.isArray(attributes) - ? { ".activeWhen": attributes } - : attributes ?? {}; - if (path) { - properties["path"] = path; - } - return html` - ${label ? html`${label}` : nothing} - ${map(children, renderOneSidebarItem)} - `; - }; - - // prettier-ignore - return html` - ${this.renderNewVersionMessage()} - ${this.renderImpersonationMessage()} - ${map(sidebarContent, renderOneSidebarItem)} - ${this.renderEnterpriseMessage()} - `; + return localSidebar.map(localToSidebarEntry); } - renderNewVersionMessage() { - return this.version && this.version !== VERSION - ? html` - - ${msg("A newer version of the frontend is available.")} - - ` - : nothing; - } - - renderImpersonationMessage() { - const reload = () => - new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => { - window.location.reload(); - }); - - return this.impersonation - ? html` - ${msg( - str`You're currently impersonating ${this.impersonation}. Click to stop.`, - )} - ` - : nothing; - } - - renderEnterpriseMessage() { - return this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise) - ? html` - - ${msg("Enterprise")} - - ${msg("Licenses")} - - - ` - : nothing; + render() { + return html` `; } } diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index 69604b13f..732375560 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -1,9 +1,10 @@ import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/sidebar/SidebarBrand"; import "@goauthentik/elements/sidebar/SidebarUser"; +import "@goauthentik/elements/sidebar/SidebarItems"; import { CSSResult, TemplateResult, css, html } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; @@ -11,8 +12,13 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { UiThemeEnum } from "@goauthentik/api"; +import type { SidebarEntry } from "./SidebarItems"; + @customElement("ak-sidebar") export class Sidebar extends AKElement { + @property({ type: Array }) + entries: SidebarEntry[] = []; + static get styles(): CSSResult[] { return [ PFBase, @@ -45,7 +51,8 @@ export class Sidebar extends AKElement { height: 100%; overflow-y: hidden; } - .pf-c-nav__list { + + ak-sidebar-items { flex-grow: 1; overflow-y: auto; } @@ -66,14 +73,10 @@ export class Sidebar extends AKElement { } render(): TemplateResult { - return html`