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,
|
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";
|
||||||
|
@ -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")]]]]
|
||||||
: [];
|
: [];
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -16,7 +16,7 @@ export const flowDesignationTable: FlowDesignationPair[] = [
|
||||||
[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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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 New Issue