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:
parent
a0dfe7ce78
commit
a9886b047e
|
@ -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")]]]]
|
||||
: [];
|
||||
|
|
|
@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
|
|||
|
||||
export const ConnectionTypesController = createTypesController(
|
||||
() => new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList(),
|
||||
"/outpost/integrations"
|
||||
"/outpost/integrations",
|
||||
);
|
||||
|
||||
export default ConnectionTypesController;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
|
|||
|
||||
export const PolicyTypesController = createTypesController(
|
||||
() => new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList(),
|
||||
"/policy/policies"
|
||||
"/policy/policies",
|
||||
);
|
||||
|
||||
export default PolicyTypesController;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
|
|||
|
||||
export const ProviderTypesController = createTypesController(
|
||||
() => new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(),
|
||||
"/core/providers"
|
||||
"/core/providers",
|
||||
);
|
||||
|
||||
export default ProviderTypesController;
|
||||
|
|
|
@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
|
|||
|
||||
export const SourceTypesController = createTypesController(
|
||||
() => new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList(),
|
||||
"/core/sources"
|
||||
"/core/sources",
|
||||
);
|
||||
|
||||
export default SourceTypesController;
|
||||
|
|
|
@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController";
|
|||
|
||||
export const StageTypesController = createTypesController(
|
||||
() => new StagesApi(DEFAULT_CONFIG).stagesAllTypesList(),
|
||||
"/flow/stages"
|
||||
"/flow/stages",
|
||||
);
|
||||
|
||||
export default StageTypesController;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>`;
|
||||
}
|
||||
|
||||
|
|
21
web/src/elements/sidebar/types.ts
Normal file
21
web/src/elements/sidebar/types.ts
Normal 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;
|
46
web/src/elements/sidebar/utils.ts
Normal file
46
web/src/elements/sidebar/utils.ts
Normal 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;
|
||||
}
|
Reference in a new issue