From a9886b047e77b05427663abb83d921a8b226d848 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Fri, 17 Nov 2023 14:47:47 -0800 Subject: [PATCH] web: further refinements to the sidebar This commit restores the onHashChange functionality, using an on-demand reverse map (there really aren't that many objects in the nav tree) to make sure all of the parent entities are also listed in the "expanded" listing to make sure the target object is still visible. Along the way, several type lever errors were corrected. Two major pieces of functionality were extracted from the Sidebar function as they're mostly consumers/filters of the information provided, and don't need to be in the Sidebar itself. --- web/src/admin/AdminInterface/AdminSidebar.ts | 28 +++-- .../ConnectionTypesController.ts | 2 +- .../SidebarEntries/GenericTypesController.ts | 8 +- .../SidebarEntries/PolicyTypesController.ts | 2 +- .../PropertyMappingsController.ts | 2 +- .../SidebarEntries/ProviderTypesController.ts | 2 +- .../SidebarEntries/SourceTypesController.ts | 2 +- .../SidebarEntries/StageTypesController.ts | 2 +- web/src/admin/flows/utils.ts | 26 ++--- web/src/common/labels.ts | 2 +- web/src/elements/sidebar/Sidebar.ts | 2 +- web/src/elements/sidebar/SidebarItems.ts | 108 +++++++++--------- web/src/elements/sidebar/types.ts | 21 ++++ web/src/elements/sidebar/utils.ts | 46 ++++++++ 14 files changed, 166 insertions(+), 87 deletions(-) create mode 100644 web/src/elements/sidebar/types.ts create mode 100644 web/src/elements/sidebar/utils.ts diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index 9cc8148ef..42b381095 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -10,7 +10,7 @@ import { SidebarAttributes, SidebarEntry, SidebarEventHandler, -} from "@goauthentik/elements/sidebar/SidebarItems"; +} from "@goauthentik/elements/sidebar/types"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { consume } from "@lit-labs/context"; @@ -38,7 +38,7 @@ import StageTypesController from "./SidebarEntries/StageTypesController"; * it as an overlay or as a push. * 2. Control what content the sidebar will receive. The sidebar takes a tree, maximally three deep, * of type SidebarEventHandler. - */ + */ type SidebarUrl = string; @@ -144,18 +144,30 @@ export class AkAdminSidebar extends AKElement { window.location.reload(); }); - // prettier-ignore - const newVersionMessage: LocalSidebarEntry[] = this.version && this.version !== VERSION - ? [["https://goauthentik.io", msg("A newer version of the frontend is available."), { "?highlight": 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.`)]] + ? [ + [ + reload, + msg( + str`You're currently impersonating ${this.impersonation}. Click to stop.`, + ), + ], + ] : []; const enterpriseMenu: LocalSidebarEntry[] = this.config?.capabilities.includes( - CapabilitiesEnum.IsEnterprise + CapabilitiesEnum.IsEnterprise, ) ? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]] : []; diff --git a/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts index 49c0bd67b..5ecebe5fe 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const ConnectionTypesController = createTypesController( () => new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList(), - "/outpost/integrations" + "/outpost/integrations", ); export default ConnectionTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts index 84b1f8f5c..69239257a 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts @@ -1,5 +1,7 @@ import { ReactiveControllerHost } from "lit"; + import { TypeCreate } from "@goauthentik/api"; + import { LocalSidebarEntry } from "../AdminSidebar"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -27,7 +29,11 @@ const typeCreateToSidebar = (baseUrl: string, tcreate: TypeCreate[]): LocalSideb * */ -export function createTypesController(fetch: Fetcher, path: string, converter = typeCreateToSidebar) { +export function createTypesController( + fetch: Fetcher, + path: string, + converter = typeCreateToSidebar, +) { return class GenericTypesController { createTypes: TypeCreate[] = []; host: ReactiveControllerHost; diff --git a/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts index ad40fe19f..9cc9cddb1 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const PolicyTypesController = createTypesController( () => new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList(), - "/policy/policies" + "/policy/policies", ); export default PolicyTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts b/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts index 6f0c47f41..47a92de61 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const PropertyMappingsController = createTypesController( () => new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList(), - "/core/property-mappings" + "/core/property-mappings", ); export default PropertyMappingsController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts index bdb98f8f5..3b727e3ad 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const ProviderTypesController = createTypesController( () => new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(), - "/core/providers" + "/core/providers", ); export default ProviderTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts index 04b7cb611..2d607a4c6 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const SourceTypesController = createTypesController( () => new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList(), - "/core/sources" + "/core/sources", ); export default SourceTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts index a497eaf3e..b56296788 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const StageTypesController = createTypesController( () => new StagesApi(DEFAULT_CONFIG).stagesAllTypesList(), - "/flow/stages" + "/flow/stages", ); export default StageTypesController; diff --git a/web/src/admin/flows/utils.ts b/web/src/admin/flows/utils.ts index 9dadc3a8b..96bfac0a5 100644 --- a/web/src/admin/flows/utils.ts +++ b/web/src/admin/flows/utils.ts @@ -9,14 +9,14 @@ export function RenderFlowOption(flow: Flow): string { type FlowDesignationPair = [FlowDesignationEnum, string]; export const flowDesignationTable: FlowDesignationPair[] = [ - [FlowDesignationEnum.Authentication, msg("Authentication")], - [FlowDesignationEnum.Authorization, msg("Authorization")], - [FlowDesignationEnum.Enrollment, msg("Enrollment")], - [FlowDesignationEnum.Invalidation, msg("Invalidation")], - [FlowDesignationEnum.Recovery, msg("Recovery")], - [FlowDesignationEnum.StageConfiguration, msg("Stage Configuration")], - [FlowDesignationEnum.Unenrollment, msg("Unenrollment")], -] + [FlowDesignationEnum.Authentication, msg("Authentication")], + [FlowDesignationEnum.Authorization, msg("Authorization")], + [FlowDesignationEnum.Enrollment, msg("Enrollment")], + [FlowDesignationEnum.Invalidation, msg("Invalidation")], + [FlowDesignationEnum.Recovery, msg("Recovery")], + [FlowDesignationEnum.StageConfiguration, msg("Stage Configuration")], + [FlowDesignationEnum.Unenrollment, msg("Unenrollment")], +]; // prettier-ignore const flowDesignations = new Map(flowDesignationTable); @@ -26,11 +26,11 @@ export function DesignationToLabel(designation: FlowDesignationEnum): string { } const layoutToLabel = new Map([ - [LayoutEnum.Stacked, msg("Stacked")], - [LayoutEnum.ContentLeft, msg("Content left")], - [LayoutEnum.ContentRight, msg("Content right")], - [LayoutEnum.SidebarLeft, msg("Sidebar left")], - [LayoutEnum.SidebarRight, msg("Sidebar right")], + [LayoutEnum.Stacked, msg("Stacked")], + [LayoutEnum.ContentLeft, msg("Content left")], + [LayoutEnum.ContentRight, msg("Content right")], + [LayoutEnum.SidebarLeft, msg("Sidebar left")], + [LayoutEnum.SidebarRight, msg("Sidebar right")], ]); export function LayoutToLabel(layout: LayoutEnum): string { diff --git a/web/src/common/labels.ts b/web/src/common/labels.ts index fc76406b1..7e2d6ca8d 100644 --- a/web/src/common/labels.ts +++ b/web/src/common/labels.ts @@ -45,7 +45,7 @@ export const eventActionLabels: Pair[] = [ [EventActions.ModelDeleted, msg("Model deleted")], [EventActions.EmailSent, msg("Email sent")], [EventActions.UpdateAvailable, msg("Update available")], -] +]; export const eventActionToLabel = new Map(eventActionLabels); diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index 211825784..c07dcf828 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -12,7 +12,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { UiThemeEnum } from "@goauthentik/api"; -import type { SidebarEntry } from "./SidebarItems"; +import type { SidebarEntry } from "./types"; @customElement("ak-sidebar") export class Sidebar extends AKElement { diff --git a/web/src/elements/sidebar/SidebarItems.ts b/web/src/elements/sidebar/SidebarItems.ts index 28fc2f7fc..53b56bb57 100644 --- a/web/src/elements/sidebar/SidebarItems.ts +++ b/web/src/elements/sidebar/SidebarItems.ts @@ -11,29 +11,8 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { UiThemeEnum } from "@goauthentik/api"; -// The second attribute type is of string[] to help with the 'activeWhen' control, which was -// commonplace and singular enough to merit its own handler. -export type SidebarEventHandler = () => void; - -export type SidebarAttributes = { - isAbsoluteLink?: boolean | (() => boolean); - highlight?: boolean | (() => boolean); - expanded?: boolean | (() => boolean); - activeWhen?: string[]; - isActive?: boolean; -}; - -export type SidebarEntry = { - path: string | SidebarEventHandler | null; - label: string; - attributes?: SidebarAttributes | null; // eslint-disable-line - children?: SidebarEntry[]; -}; - -// Typescript requires the type here to correctly type the recursive path -export type SidebarRenderer = (_: SidebarEntry) => TemplateResult; - -const entryKey = (entry: SidebarEntry) => `${entry.path || "no-path"}:${entry.label}`; +import type { SidebarEntry } from "./types"; +import { entryKey, findMatchForNavbarUrl, makeParentMap } from "./utils"; @customElement("ak-sidebar-items") export class SidebarItems extends AKElement { @@ -146,14 +125,22 @@ export class SidebarItems extends AKElement { super.disconnectedCallback(); } - render(): TemplateResult { - const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light }; + expandParents(entry: SidebarEntry) { + const reverseMap = makeParentMap(this.entries); + let start: SidebarEntry | undefined = reverseMap.get(entry); + while (start) { + this.expanded.add(entryKey(start)); + start = reverseMap.get(start); + } + } - return html` `; + onHashChange() { + this.current = ""; + const match = findMatchForNavbarUrl(this.entries); + if (match) { + this.current = entryKey(match); + this.expandParents(match); + } } toggleExpand(entry: SidebarEntry) { @@ -166,31 +153,39 @@ export class SidebarItems extends AKElement { this.requestUpdate(); } + render(): TemplateResult { + const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light }; + + return html` `; + } + renderItem(entry: SidebarEntry) { - const { path, label, attributes, children } = entry; // Ensure the attributes are undefined, not null; they can be null in the placeholders, but // not when being forwarded to the correct renderer. - const attr = attributes ?? undefined; - const hasChildren = !!(children && children.length > 0); + const hasChildren = !!(entry.children && entry.children.length > 0); - // This is grossly imperative, in that it HAS to come before the content is rendered - // to make sure the content gets the right settings with respect to expansion. - if (attr?.expanded) { + // This is grossly imperative, in that it HAS to come before the content is rendered to make + // sure the content gets the right settings with respect to expansion. + if (entry.attributes?.expanded) { this.expanded.add(entryKey(entry)); - delete attr.expanded; + delete entry.attributes.expanded; } const content = - path && hasChildren + entry.path && hasChildren ? this.renderLinkAndChildren(entry) : hasChildren - ? this.renderLabelAndChildren(entry) - : path - ? this.renderLink(label, path, attr) - : this.renderLabel(label, attr); + ? this.renderLabelAndChildren(entry) + : entry.path + ? this.renderLink(entry) + : this.renderLabel(entry); const expanded = { - "highlighted": !!attr?.highlight, + "highlighted": !!entry.attributes?.highlight, "pf-m-expanded": this.expanded.has(entryKey(entry)), "pf-m-expandable": hasChildren, }; @@ -198,32 +193,31 @@ export class SidebarItems extends AKElement { return html`
  • ${content}
  • `; } - toLinkClasses(attr: SidebarAttributes) { + getLinkClasses(entry: SidebarEntry) { + const a = entry.attributes ?? {}; return { - "pf-m-current": !!attr.isActive, + "pf-m-current": a == this.current, "pf-c-nav__link": true, - "highlight": !!(typeof attr.highlight === "function" - ? attr.highlight() - : attr.highlight), + "highlight": !!(typeof a.highlight === "function" ? a.highlight() : a.highlight), }; } - renderLabel(label: string, attr: SidebarAttributes = {}) { - return html`
    ${label}
    `; + renderLabel(entry: SidebarEntry) { + return html`
    ${entry.label}
    `; } // note the responsibilities pushed up to the caller - renderLink(label: string, path: string | SidebarEventHandler, attr: SidebarAttributes = {}) { - if (typeof path === "function") { - return html` - ${label} + renderLink(entry: SidebarEntry) { + if (typeof entry.path === "function") { + return html` + ${entry.label} `; } return html` - ${label} + ${entry.label} `; } diff --git a/web/src/elements/sidebar/types.ts b/web/src/elements/sidebar/types.ts new file mode 100644 index 000000000..a8ee73331 --- /dev/null +++ b/web/src/elements/sidebar/types.ts @@ -0,0 +1,21 @@ +import { TemplateResult } from "lit"; + +export type SidebarEventHandler = () => void; + +export type SidebarAttributes = { + isAbsoluteLink?: boolean | (() => boolean); + highlight?: boolean | (() => boolean); + expanded?: boolean | (() => boolean); + activeWhen?: string[]; + isActive?: boolean; +}; + +export type SidebarEntry = { + path: string | SidebarEventHandler | null; + label: string; + attributes?: SidebarAttributes | null; // eslint-disable-line + children?: SidebarEntry[]; +}; + +// Typescript requires the type here to correctly type the recursive path +export type SidebarRenderer = (_: SidebarEntry) => TemplateResult; diff --git a/web/src/elements/sidebar/utils.ts b/web/src/elements/sidebar/utils.ts new file mode 100644 index 000000000..bece5d7a9 --- /dev/null +++ b/web/src/elements/sidebar/utils.ts @@ -0,0 +1,46 @@ +import { ROUTE_SEPARATOR } from "@goauthentik/common/constants"; + +import { SidebarEntry } from "./types"; + +export function entryKey(entry: SidebarEntry) { + return `${entry.path || "no-path"}:${entry.label}`; +} + +export function makeParentMap(entries: SidebarEntry[]) { + const reverseMap = new WeakMap(); + function reverse(entry: SidebarEntry) { + (entry.children ?? []).forEach((e) => { + reverseMap.set(e, entry); + reverse(e); + }); + } + entries.forEach(reverse); + return reverseMap; +} + +function scanner(entry: SidebarEntry, activePath: string): SidebarEntry | undefined { + for (const matcher of entry.attributes?.activeWhen ?? []) { + const matchtest = new RegExp(matcher); + if (matchtest.test(activePath)) { + return entry; + } + const match: SidebarEntry | undefined = (entry.children ?? []).find((e) => + scanner(e, activePath), + ); + if (match) { + return match; + } + } + return undefined; +} + +export function findMatchForNavbarUrl(entries: SidebarEntry[]) { + const activePath = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0]; + for (const entry of entries) { + const result = scanner(entry, activePath); + if (result) { + return result; + } + } + return undefined; +}