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.
This commit is contained in:
Ken Sternberg 2023-11-17 14:47:47 -08:00
parent a0dfe7ce78
commit a9886b047e
14 changed files with 166 additions and 87 deletions

View File

@ -10,7 +10,7 @@ import {
SidebarAttributes, SidebarAttributes,
SidebarEntry, SidebarEntry,
SidebarEventHandler, SidebarEventHandler,
} from "@goauthentik/elements/sidebar/SidebarItems"; } from "@goauthentik/elements/sidebar/types";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { consume } from "@lit-labs/context"; import { consume } from "@lit-labs/context";
@ -38,7 +38,7 @@ import StageTypesController from "./SidebarEntries/StageTypesController";
* it as an overlay or as a push. * it as an overlay or as a push.
* 2. Control what content the sidebar will receive. The sidebar takes a tree, maximally three deep, * 2. Control what content the sidebar will receive. The sidebar takes a tree, maximally three deep,
* of type SidebarEventHandler. * of type SidebarEventHandler.
*/ */
type SidebarUrl = string; type SidebarUrl = string;
@ -144,18 +144,30 @@ export class AkAdminSidebar extends AKElement {
window.location.reload(); window.location.reload();
}); });
// prettier-ignore const newVersionMessage: LocalSidebarEntry[] =
const newVersionMessage: LocalSidebarEntry[] = this.version && this.version !== VERSION this.version && this.version !== VERSION
? [["https://goauthentik.io", msg("A newer version of the frontend is available."), { "?highlight": true }]] ? [
[
"https://goauthentik.io",
msg("A newer version of the frontend is available."),
{ highlight: true },
],
]
: []; : [];
// prettier-ignore
const impersonationMessage: LocalSidebarEntry[] = this.impersonation 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( const enterpriseMenu: LocalSidebarEntry[] = this.config?.capabilities.includes(
CapabilitiesEnum.IsEnterprise CapabilitiesEnum.IsEnterprise,
) )
? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]] ? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]]
: []; : [];

View File

@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
export const ConnectionTypesController = createTypesController( export const ConnectionTypesController = createTypesController(
() => new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList(), () => new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList(),
"/outpost/integrations" "/outpost/integrations",
); );
export default ConnectionTypesController; export default ConnectionTypesController;

View File

@ -1,5 +1,7 @@
import { ReactiveControllerHost } from "lit"; import { ReactiveControllerHost } from "lit";
import { TypeCreate } from "@goauthentik/api"; import { TypeCreate } from "@goauthentik/api";
import { LocalSidebarEntry } from "../AdminSidebar"; import { LocalSidebarEntry } from "../AdminSidebar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 { return class GenericTypesController {
createTypes: TypeCreate[] = []; createTypes: TypeCreate[] = [];
host: ReactiveControllerHost; host: ReactiveControllerHost;

View File

@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
export const PolicyTypesController = createTypesController( export const PolicyTypesController = createTypesController(
() => new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList(), () => new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList(),
"/policy/policies" "/policy/policies",
); );
export default PolicyTypesController; export default PolicyTypesController;

View File

@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
export const PropertyMappingsController = createTypesController( export const PropertyMappingsController = createTypesController(
() => new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList(), () => new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList(),
"/core/property-mappings" "/core/property-mappings",
); );
export default PropertyMappingsController; export default PropertyMappingsController;

View File

@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
export const ProviderTypesController = createTypesController( export const ProviderTypesController = createTypesController(
() => new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(), () => new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(),
"/core/providers" "/core/providers",
); );
export default ProviderTypesController; export default ProviderTypesController;

View File

@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
export const SourceTypesController = createTypesController( export const SourceTypesController = createTypesController(
() => new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList(), () => new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList(),
"/core/sources" "/core/sources",
); );
export default SourceTypesController; export default SourceTypesController;

View File

@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
export const StageTypesController = createTypesController( export const StageTypesController = createTypesController(
() => new StagesApi(DEFAULT_CONFIG).stagesAllTypesList(), () => new StagesApi(DEFAULT_CONFIG).stagesAllTypesList(),
"/flow/stages" "/flow/stages",
); );
export default StageTypesController; export default StageTypesController;

View File

@ -9,14 +9,14 @@ export function RenderFlowOption(flow: Flow): string {
type FlowDesignationPair = [FlowDesignationEnum, string]; type FlowDesignationPair = [FlowDesignationEnum, string];
export const flowDesignationTable: FlowDesignationPair[] = [ export const flowDesignationTable: FlowDesignationPair[] = [
[FlowDesignationEnum.Authentication, msg("Authentication")], [FlowDesignationEnum.Authentication, msg("Authentication")],
[FlowDesignationEnum.Authorization, msg("Authorization")], [FlowDesignationEnum.Authorization, msg("Authorization")],
[FlowDesignationEnum.Enrollment, msg("Enrollment")], [FlowDesignationEnum.Enrollment, msg("Enrollment")],
[FlowDesignationEnum.Invalidation, msg("Invalidation")], [FlowDesignationEnum.Invalidation, msg("Invalidation")],
[FlowDesignationEnum.Recovery, msg("Recovery")], [FlowDesignationEnum.Recovery, msg("Recovery")],
[FlowDesignationEnum.StageConfiguration, msg("Stage Configuration")], [FlowDesignationEnum.StageConfiguration, msg("Stage Configuration")],
[FlowDesignationEnum.Unenrollment, msg("Unenrollment")], [FlowDesignationEnum.Unenrollment, msg("Unenrollment")],
] ];
// prettier-ignore // prettier-ignore
const flowDesignations = new Map(flowDesignationTable); const flowDesignations = new Map(flowDesignationTable);
@ -26,11 +26,11 @@ export function DesignationToLabel(designation: FlowDesignationEnum): string {
} }
const layoutToLabel = new Map<LayoutEnum, string>([ const layoutToLabel = new Map<LayoutEnum, string>([
[LayoutEnum.Stacked, msg("Stacked")], [LayoutEnum.Stacked, msg("Stacked")],
[LayoutEnum.ContentLeft, msg("Content left")], [LayoutEnum.ContentLeft, msg("Content left")],
[LayoutEnum.ContentRight, msg("Content right")], [LayoutEnum.ContentRight, msg("Content right")],
[LayoutEnum.SidebarLeft, msg("Sidebar left")], [LayoutEnum.SidebarLeft, msg("Sidebar left")],
[LayoutEnum.SidebarRight, msg("Sidebar right")], [LayoutEnum.SidebarRight, msg("Sidebar right")],
]); ]);
export function LayoutToLabel(layout: LayoutEnum): string { export function LayoutToLabel(layout: LayoutEnum): string {

View File

@ -45,7 +45,7 @@ export const eventActionLabels: Pair<EventActions>[] = [
[EventActions.ModelDeleted, msg("Model deleted")], [EventActions.ModelDeleted, msg("Model deleted")],
[EventActions.EmailSent, msg("Email sent")], [EventActions.EmailSent, msg("Email sent")],
[EventActions.UpdateAvailable, msg("Update available")], [EventActions.UpdateAvailable, msg("Update available")],
] ];
export const eventActionToLabel = new Map<EventActions | undefined, string>(eventActionLabels); export const eventActionToLabel = new Map<EventActions | undefined, string>(eventActionLabels);

View File

@ -12,7 +12,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { UiThemeEnum } from "@goauthentik/api"; import { UiThemeEnum } from "@goauthentik/api";
import type { SidebarEntry } from "./SidebarItems"; import type { SidebarEntry } from "./types";
@customElement("ak-sidebar") @customElement("ak-sidebar")
export class Sidebar extends AKElement { export class Sidebar extends AKElement {

View File

@ -11,29 +11,8 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { UiThemeEnum } from "@goauthentik/api"; import { UiThemeEnum } from "@goauthentik/api";
// The second attribute type is of string[] to help with the 'activeWhen' control, which was import type { SidebarEntry } from "./types";
// commonplace and singular enough to merit its own handler. import { entryKey, findMatchForNavbarUrl, makeParentMap } from "./utils";
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}`;
@customElement("ak-sidebar-items") @customElement("ak-sidebar-items")
export class SidebarItems extends AKElement { export class SidebarItems extends AKElement {
@ -146,14 +125,22 @@ export class SidebarItems extends AKElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
render(): TemplateResult { expandParents(entry: SidebarEntry) {
const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light }; 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` <nav class="pf-c-nav ${classMap(lightThemed)}" aria-label="Navigation"> onHashChange() {
<ul class="pf-c-nav__list"> this.current = "";
${map(this.entries, this.renderItem)} const match = findMatchForNavbarUrl(this.entries);
</ul> if (match) {
</nav>`; this.current = entryKey(match);
this.expandParents(match);
}
} }
toggleExpand(entry: SidebarEntry) { toggleExpand(entry: SidebarEntry) {
@ -166,31 +153,39 @@ export class SidebarItems extends AKElement {
this.requestUpdate(); this.requestUpdate();
} }
render(): TemplateResult {
const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light };
return html` <nav class="pf-c-nav ${classMap(lightThemed)}" aria-label="Navigation">
<ul class="pf-c-nav__list">
${map(this.entries, this.renderItem)}
</ul>
</nav>`;
}
renderItem(entry: SidebarEntry) { renderItem(entry: SidebarEntry) {
const { path, label, attributes, children } = entry;
// Ensure the attributes are undefined, not null; they can be null in the placeholders, but // Ensure the attributes are undefined, not null; they can be null in the placeholders, but
// not when being forwarded to the correct renderer. // not when being forwarded to the correct renderer.
const attr = attributes ?? undefined; const hasChildren = !!(entry.children && entry.children.length > 0);
const hasChildren = !!(children && children.length > 0);
// This is grossly imperative, in that it HAS to come before the content is rendered // This is grossly imperative, in that it HAS to come before the content is rendered to make
// to make sure the content gets the right settings with respect to expansion. // sure the content gets the right settings with respect to expansion.
if (attr?.expanded) { if (entry.attributes?.expanded) {
this.expanded.add(entryKey(entry)); this.expanded.add(entryKey(entry));
delete attr.expanded; delete entry.attributes.expanded;
} }
const content = const content =
path && hasChildren entry.path && hasChildren
? this.renderLinkAndChildren(entry) ? this.renderLinkAndChildren(entry)
: hasChildren : hasChildren
? this.renderLabelAndChildren(entry) ? this.renderLabelAndChildren(entry)
: path : entry.path
? this.renderLink(label, path, attr) ? this.renderLink(entry)
: this.renderLabel(label, attr); : this.renderLabel(entry);
const expanded = { const expanded = {
"highlighted": !!attr?.highlight, "highlighted": !!entry.attributes?.highlight,
"pf-m-expanded": this.expanded.has(entryKey(entry)), "pf-m-expanded": this.expanded.has(entryKey(entry)),
"pf-m-expandable": hasChildren, "pf-m-expandable": hasChildren,
}; };
@ -198,32 +193,31 @@ export class SidebarItems extends AKElement {
return html`<li class="pf-c-nav__item ${classMap(expanded)}">${content}</li>`; return html`<li class="pf-c-nav__item ${classMap(expanded)}">${content}</li>`;
} }
toLinkClasses(attr: SidebarAttributes) { getLinkClasses(entry: SidebarEntry) {
const a = entry.attributes ?? {};
return { return {
"pf-m-current": !!attr.isActive, "pf-m-current": a == this.current,
"pf-c-nav__link": true, "pf-c-nav__link": true,
"highlight": !!(typeof attr.highlight === "function" "highlight": !!(typeof a.highlight === "function" ? a.highlight() : a.highlight),
? attr.highlight()
: attr.highlight),
}; };
} }
renderLabel(label: string, attr: SidebarAttributes = {}) { renderLabel(entry: SidebarEntry) {
return html`<div class=${classMap(this.toLinkClasses(attr))}>${label}</div>`; return html`<div class=${classMap(this.getLinkClasses(entry))}>${entry.label}</div>`;
} }
// note the responsibilities pushed up to the caller // note the responsibilities pushed up to the caller
renderLink(label: string, path: string | SidebarEventHandler, attr: SidebarAttributes = {}) { renderLink(entry: SidebarEntry) {
if (typeof path === "function") { if (typeof entry.path === "function") {
return html` <a @click=${path} class=${classMap(this.toLinkClasses(attr))}> return html` <a @click=${entry.path} class=${classMap(this.getLinkClasses(entry))}>
${label} ${entry.label}
</a>`; </a>`;
} }
return html` <a return html` <a
href="${attr.isAbsoluteLink ? "" : "#"}${path}" href="${entry.attributes?.isAbsoluteLink ? "" : "#"}${entry.path}"
class=${classMap(this.toLinkClasses(attr))} class=${classMap(this.getLinkClasses(entry))}
> >
${label} ${entry.label}
</a>`; </a>`;
} }

View File

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

View File

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