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,
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")]]]]
: [];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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, string>([
[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 {

View file

@ -45,7 +45,7 @@ export const eventActionLabels: Pair<EventActions>[] = [
[EventActions.ModelDeleted, msg("Model deleted")],
[EventActions.EmailSent, msg("Email sent")],
[EventActions.UpdateAvailable, msg("Update available")],
]
];
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 type { SidebarEntry } from "./SidebarItems";
import type { SidebarEntry } from "./types";
@customElement("ak-sidebar")
export class Sidebar extends AKElement {

View file

@ -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` <nav class="pf-c-nav ${classMap(lightThemed)}" aria-label="Navigation">
<ul class="pf-c-nav__list">
${map(this.entries, this.renderItem)}
</ul>
</nav>`;
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` <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) {
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`<li class="pf-c-nav__item ${classMap(expanded)}">${content}</li>`;
}
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`<div class=${classMap(this.toLinkClasses(attr))}>${label}</div>`;
renderLabel(entry: SidebarEntry) {
return html`<div class=${classMap(this.getLinkClasses(entry))}>${entry.label}</div>`;
}
// note the responsibilities pushed up to the caller
renderLink(label: string, path: string | SidebarEventHandler, attr: SidebarAttributes = {}) {
if (typeof path === "function") {
return html` <a @click=${path} class=${classMap(this.toLinkClasses(attr))}>
${label}
renderLink(entry: SidebarEntry) {
if (typeof entry.path === "function") {
return html` <a @click=${entry.path} class=${classMap(this.getLinkClasses(entry))}>
${entry.label}
</a>`;
}
return html` <a
href="${attr.isAbsoluteLink ? "" : "#"}${path}"
class=${classMap(this.toLinkClasses(attr))}
href="${entry.attributes?.isAbsoluteLink ? "" : "#"}${entry.path}"
class=${classMap(this.getLinkClasses(entry))}
>
${label}
${entry.label}
</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;
}